// Implementation of the many authentication functions. package main import ( "crypto/sha256" "crypto/subtle" "database/sql" "fmt" "io" "net/http" "net/smtp" "path/filepath" "strings" "time" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" _ "github.com/go-sql-driver/mysql" "strk.kbt.io/projects/go/libravatar" ) // UserForm is used for capturing form data from the login page and adds decoration for JSON. // see https://chenyitian.gitbooks.io/gin-tutorials/tdd/2.html type UserForm struct { Username string `json:"username" form:"username" binding:"required"` Password string `json:"-" form:"password" binding:"required"` Email string `json:"-" form:"email"` RememberMe string `json:"rememberMe" form:"rememberMe"` } type ChangePasswordForm struct { OldPassword string `json:"-" form:"oldpassword" binding:"required"` NewPassword string `json:"-" form:"newpassword" binding:"required"` ConfirmNewPassword string `json:"-" form:"confirmnewpassword" binding:"required"` T string `form:"t"` // this field is only used when someone changes the password via email-sent link: it includes the link token } type ResetPasswordForm struct { Email string `json:"email" form:"email" binding:"required"` GPG string `json:"gpg" form:"gpg"` // GPG fingerprint to encrypt email, if provided (gwyneth 20200705) } // Token is required by the ShouldBindURI() call, it cannot be a simple string for some reason... type Token struct { Payload string `uri:"token"` } // isUserValid checks the database for a valid user/pass combination. It returns a boolean. the email and the UUID. Yes, it's ugly. // TODO(gwyneth): Figure out a better way to extract the email data and store it safely. // TODO(gwyneth): Probably, this ought to return (string, string, bool) to be a bit more consistent... // // or even a UserForm, which could be passed as value and returned with extra fields filled in (gwyneth 20200703) func isUserValid(username, password string) (bool, string, string) { // split username into space-separated fields theUsername := strings.Fields(strings.TrimSpace(username)) if len(theUsername) < 2 { config.LogWarnf("Invalid first name/last name: %q does not contain spaces! Returning with error", username) return false, "", "" } // We'll just use the first two avatarFirstName := theUsername[0] avatarLastName := theUsername[1] // check if user exists if *config["dsn"] == "" { config.LogFatal("Please configure the DSN for accessing your OpenSimulator database; this application won't work without that") } db, err := sql.Open("mysql", *config["dsn"]) // presumes mysql for now checkErrFatal(err) defer db.Close() var principalID, email string err = db.QueryRow("SELECT PrincipalID, Email FROM UserAccounts WHERE FirstName = ? AND LastName = ?", avatarFirstName, avatarLastName).Scan(&principalID, &email) // there can be only one, or our database is corrupted if err != nil { // db.QueryRow() will return ErrNoRows, which will be passed to Scan() config.LogDebug("user", username, "not in database") return false, "", "" } config.LogDebugf("Avatar data from database: '%s %s' (%s) Email: %q", avatarFirstName, avatarLastName, principalID, email) var passwordHash, passwordSalt string err = db.QueryRow("SELECT passwordHash, passwordSalt FROM auth WHERE UUID = ?", principalID).Scan(&passwordHash, &passwordSalt) if err != nil { // db.QueryRow() will return ErrNoRows, which will be passed to Scan() config.LogDebug("password not in database") return false, "", "" } // md5(md5("password") + ":" + passwordSalt) according to http://opensimulator.org/wiki/Auth var hashedPassword, hashed, interior string // make sure they are strings, or comparison might fail hashedPassword = GetMD5Hash(password) interior = hashedPassword + ":" + passwordSalt hashed = GetMD5Hash(interior) // see OpenSimulator source code in /opensim/OpenSim/Services/PasswordAuthenticationService, method Authenticate() // we'll simplify the above code to a one-liner which will be more legible once we debug this properly! (gwyneth 20200626) config.LogDebugf("md5(password) = %q, (md5(password) + \":\" + passwordSalt) = %q, md5(md5(password) + \":\" + passwordSalt) = %q, which we must compare with %q", hashedPassword, interior, hashed, passwordHash) // compare and see if it matches if passwordHash == hashed { // authenticated! now set session cookie and do all the magic config.LogInfof("User %q authenticated.", username) return true, email, principalID } else { config.LogWarnf("Invalid authentication for %q — either user not found or password is wrong", username) return false, "", "" } // never reached // commented out to make sure that `go vet` doesn't crash here (20210117) // return true, email, principalID } // performLogin is what the login form will call as the method to pass username/password. func performLogin(c *gin.Context) { var oneUser UserForm session := sessions.Default(c) defer func() { // TODO(gwyneth): we ought to deal with an empty email and/or an empty avatar_url config.LogDebugf("Session for %q set: token %q - Also, Libravatar is %q", session.Get("Username"), session.Get("Token"), session.Get("Libravatar")) }() if c.Bind(&oneUser) != nil { // nil means no errors // if err := c.ShouldBind(&oneUser); err != nil { // this should be working; it's the 'prefered' way. But it doesn't! D'uh! (gwyneth 20200623) c.HTML(http.StatusBadRequest, "login.tpl", environment(c, gin.H{ "BoxTitle": "Login Failed", "BoxMessage": "No form data posted", "BoxType": "danger", "Debug": false, "titleCommon": *config["titleCommon"] + "What?", "logintemplate": true, })) config.LogError("No form data posted for login") return } if strings.TrimSpace(oneUser.Password) == "" { // this should not happen, as we put the password as 'required' on the decorations c.HTML(http.StatusBadRequest, "login.tpl", environment(c, gin.H{ "BoxTitle": "Login Failed", "BoxMessage": "Empty password, please try again", "BoxType": "danger", "Debug": false, "titleCommon": *config["titleCommon"] + "Oh, No!", "logintemplate": true, "WrongUsername": oneUser.Username, "WrongRememberMe": oneUser.RememberMe, })) config.LogError("The password can't be empty") return } config.LogDebugf("User: %q (password omitted) Remember me? %q", oneUser.Username, oneUser.RememberMe) if ok, email, principalID := isUserValid(oneUser.Username, oneUser.Password); ok { session.Set("Username", oneUser.Username) session.Set("UUID", principalID) session.Set("Token", generateSessionToken()) config.LogDebugf("User valid with username: %q UUID: %q Email: <%s> Token: %q", oneUser.Username, principalID, email, session.Get("Token")) session.Set("Libravatar", getLibravatar(email, oneUser.Username, 60)) if email != "" { session.Set("Email", email) // who knows, it might be useful at some point } session.Set("RememberMe", oneUser.RememberMe) session.Save() // Set up background routine to deal with incoming offline IMs (gwyneth 202006812). // During debugging, we'll simply call this once (better than nothing...) GetTopOfflineMessages(c) GetTopFeedMessages(c) } else { // invalid user, do not set cookies! config.LogErrorf("Invalid username/password combination for user %q!", oneUser.Username) c.HTML(http.StatusBadRequest, "login.tpl", environment(c, gin.H{ "BoxTitle": "Login Failed", "BoxMessage": "Invalid credentials provided", "BoxType": "danger", "Debug": false, "titleCommon": *config["titleCommon"] + "Oh, No!", "logintemplate": true, "WrongUsername": oneUser.Username, "WrongPassword": oneUser.Password, "WrongRememberMe": oneUser.RememberMe, })) return } c.Redirect(http.StatusSeeOther, "/") // see https://softwareengineering.stackexchange.com/questions/99894/why-doesnt-http-have-post-redirect and https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/303 } // logout unsets the session/cookie that contains the user authentication data. func logout(c *gin.Context) { session := sessions.Default(c) session.Set("Username", "") // this will mark the session as "written" and hopefully remove the username session.Clear() session.Options(sessions.Options{Path: "/", MaxAge: -1}) // this sets the cookie with a MaxAge of 0, session.Save() c.Redirect(http.StatusTemporaryRedirect, "/") // see https://github.com/gin-contrib/sessions/issues/29#issuecomment-376382465 } // registerNewUser is currently unimplemented (too dangerous). func registerNewUser(c *gin.Context) { var oneUser UserForm // similar to performLogin() // session := sessions.Default(c) // should not have any session if c.Bind(&oneUser) != nil { // nil means no errors c.HTML(http.StatusBadRequest, "register.tpl", environment(c, gin.H{ "BoxTitle": "Registration Failed", "BoxMessage": "No form data posted", "BoxType": "danger", "Debug": false, "titleCommon": *config["titleCommon"] + "What?", "logintemplate": true, })) config.LogError("No form data posted to register a new user") return } //(username, password string) (*UserForm, error) { config.LogInfo("Not implemented yet") c.HTML(http.StatusBadRequest, "register.tpl", environment(c, gin.H{ "BoxTitle": "Registration Failed", "BoxMessage": "No new users allowed!", "BoxType": "danger", "Debug": false, "titleCommon": *config["titleCommon"] + "Sorry!", "logintemplate": true, "WrongUsername": oneUser.Username, "WrongPassword": oneUser.Password, "WrongEmail": oneUser.Email, })) // return } // changePassword asks for the current password (unless it comes from an email link) and a new one (plus validation). Not done yet. func changePassword(c *gin.Context) { var aPasswordChange ChangePasswordForm var token string // Note: this can be called either by a logged-in user which has authenticated themselves properly // OR it can come from a user who has a valid token from an email to reset the link, and can thus enter without password session := sessions.Default(c) if c.Bind(&aPasswordChange) != nil { // nil means no errors c.HTML(http.StatusBadRequest, "change-password.tpl", environment(c, gin.H{ "BoxTitle": "Password change failed", "BoxMessage": "No form data posted", "BoxType": "danger", "Debug": false, "titleCommon": *config["titleCommon"] + "Say what?", "logintemplate": true, })) config.LogError("No form data posted for password change") return } // Ok, we got a form; so do simple checks first if aPasswordChange.NewPassword != aPasswordChange.ConfirmNewPassword { c.HTML(http.StatusBadRequest, "change-password.tpl", environment(c, gin.H{ "BoxTitle": "Password change failed", "BoxMessage": "Confirmation password does not match new password!", "BoxType": "danger", "Debug": false, "titleCommon": *config["titleCommon"] + "No way, José!", "logintemplate": true, "t": aPasswordChange.T, })) config.LogError("Confirmation password does not match new password") return } if aPasswordChange.T == "" && (aPasswordChange.NewPassword == aPasswordChange.OldPassword) { c.HTML(http.StatusBadRequest, "change-password.tpl", environment(c, gin.H{ "BoxTitle": "Password change failed", "BoxMessage": "New password must be different from the old one!", "BoxType": "danger", "Debug": false, "titleCommon": *config["titleCommon"] + "No deal!", "logintemplate": true, "t": aPasswordChange.T, })) config.LogError("New password must be different from the old one") return } var someTokens ResetPasswordTokens // this will be used to retrieve data from the KV store; we will check later on (scope issues!) if we have a valid token or not var thisUUID string // I think we have a scope issue... (gwyneth 20200714) var ok bool // Same reason as above (gwyneth 20200714) isCurrentPasswordValid := false if aPasswordChange.T == "" { // do we have a token here? // no token; so we assume that the password is valid and we can proceed to change // BUG(gwyneth): we actually need to check the password *again* to avoid someone else to change the password while // this user is still logged in if ok, _, thisUUID = isUserValid(session.Get("Username").(string), aPasswordChange.OldPassword); ok { isCurrentPasswordValid = true } else { // user probably mistyped password // we do something similar to what was done for login, i.e. present error message and place the existing information back on the form (20200714) config.LogErrorf("Invalid current password for user UUID %q while trying to change it.", thisUUID) c.HTML(http.StatusBadRequest, "change-password.tpl", environment(c, gin.H{ "BoxTitle": "Login Failed", "BoxMessage": "Incorrect current password", "BoxType": "danger", "now": formatAsYear(time.Now()), "Debug": false, "titleCommon": *config["titleCommon"] + "Whoopsie!", "logintemplate": true, "WrongOldPassword": aPasswordChange.OldPassword, "WrongNewPassword": aPasswordChange.NewPassword, "WrongConfirmNewPassword": aPasswordChange.ConfirmNewPassword, })) return } } else { // we still have a token, but to make things more secure, we validate the token again // again, first split the token token := aPasswordChange.T selector := token[:15] verifier := token[15:] sha256 := sha256.Sum256([]byte(verifier)) config.LogDebugf("Got token %q, this is selector %q and verifier %q and SHA256 %q\n", token, selector, verifier, sha256) found, err := GOSWIstore.Get(selector, &someTokens) if err == nil { if found { config.LogInfof("What we just stored for selector %q: %+v", selector, someTokens) // check if it is still valid if time.Since(someTokens.Timestamp) < (2 * time.Hour) { // valid, log user in, move to password change template if subtle.ConstantTimeCompare(sha256[:], someTokens.Verifier[:]) == 1 { config.LogInfof("Token still valid for user %q (%q)!", someTokens.Username, someTokens.UserUUID) isCurrentPasswordValid = true } } } } // whatever happened, consume the token, that is, delete it from the store, so it can't be reused if err := GOSWIstore.Delete(selector); err != nil { // this will rarely happen (unless selector == "", which should not occur) since Store.Delete() will NOT throw // errors if the key doesn't exist config.LogWarnf("Deleting %q from the store threw an error\n", err) } } if isCurrentPasswordValid { // We now need to figure out who is the user requesting this! // 1) Either this is called via the token sent by email, and it means that someTokens.UserUUID has been set; // 2) or this was called by a logged-in user changing their password, and c.Get(UUID) or session.Get(UUID) will have the UUID. if someTokens.UserUUID != "" { thisUUID = someTokens.UserUUID config.LogDebugf("Password change request via token, thisUUID is %q\n", thisUUID) } else { // some things in Gin are really awful... (gwyneth 20200714) if thisUUID2, ok2 := c.Get("UUID"); ok2 { thisUUID = thisUUID2.(string) config.LogDebugf("Password change request via logged-in user, context seems to be fine, thisUUID is %q\n", thisUUID) } } if thisUUID == "" { // it's not on the token, it's not on the context, our last hope is that it's inside the session (gwyneth 20200714). thisUUID, ok = session.Get("UUID").(string) if ok && (thisUUID != "") { config.LogDebugf("Password change request via logged-in user succeeded, retrieved from session cookie, thisUUID is %q\n", thisUUID) } else { config.LogError("Cannot change password because we cannot get a UUID for this user! Hack attempt?") c.HTML(http.StatusForbidden, "404.tpl", environment(c, gin.H{ "titleCommon": *config["titleCommon"] + " - 403", "errorcode": "403", "errortext": "User not found", "errorbody": "Unknown or invalid user, cannot proceed, please try later.", })) return } } // ok, we ought to have a valid UUID, at last we can update the password! if *config["dsn"] == "" { config.LogFatal("Please configure the DSN for accessing your OpenSimulator database; this application won't work without that") } db, err := sql.Open("mysql", *config["dsn"]) // presumes mysql for now checkErrFatal(err) defer db.Close() // using the same variables as in isUserValid(), because of the Principle of Least Surprise var hashedPassword, passwordSalt, hashed, interior string // generate salt passwordSalt = randomBase64String(32) hashedPassword = GetMD5Hash(aPasswordChange.NewPassword) interior = hashedPassword + ":" + passwordSalt hashed = GetMD5Hash(interior) result, err := db.Exec("UPDATE auth SET passwordHash = ?, passwordSalt = ? WHERE UUID = ?", hashed, passwordSalt, thisUUID) checkErr(err) if numRowsAffected, err := result.RowsAffected(); err != nil { config.LogErrorf("Updating database with new password for %q failed, error was %q\n", thisUUID, err) } else { if numRowsAffected != 1 { config.LogWarnf("Inconsistent database state after password change, numRowsAffected was %d which is unusual", numRowsAffected) c.HTML(http.StatusOK, "index.tpl", environment(c, gin.H{ "titleCommon": *config["titleCommon"] + " - Home", "BoxTitle": "Password probably not changed", "BoxType": "warning", "BoxMessage": "You might need to try again", "Content": fmt.Sprintf("Warning: your password change seems to have affected %d instances on the database, which is both surprising and unusual; please check if the change worked at all", numRowsAffected), })) return } else { config.LogInfof("Success updating database with new password for %q; %d rows affected\n", thisUUID, numRowsAffected) } } c.HTML(http.StatusOK, "index.tpl", environment(c, gin.H{ "titleCommon": *config["titleCommon"] + " - Home", "BoxTitle": "Password changed", "BoxType": "success", "BoxMessage": "Now don't forget the new one!", "Content": "Your password has been successfully changed!", })) return } c.HTML(http.StatusForbidden, "404.tpl", environment(c, gin.H{ "titleCommon": *config["titleCommon"] + " - 403", "errorcode": "403", "errortext": "Token incorrect", "errorbody": fmt.Sprintf("Either your token %q is invalid or it has expired!", token), // token may be empty })) config.LogErrorf("User UUID %q tried to use token %q but it's not valid and/or expired\n", thisUUID, token) } // ResetPasswordTokens are stored in a KV store, the key of which is the Selector. type ResetPasswordTokens struct { UserUUID string `json:"uuid"` Username string `json:"username"` Email string `json:"email"` Verifier [32]byte `json:"verifier"` // 18 chars encoded to 32-byte SHA256 hash Timestamp time.Time `json:"timestamp"` } // resetPassword is called with a POST from the form; we act upon it here. func resetPassword(c *gin.Context) { //email string) (*UserForm, error) { var aPasswordReset ResetPasswordForm //session := sessions.Default(c) if c.Bind(&aPasswordReset) != nil { // nil means no errors c.HTML(http.StatusBadRequest, "reset-password.tpl", environment(c, gin.H{ "BoxTitle": "Password reset failed", "BoxMessage": "No form data posted for password reset", "BoxType": "danger", "Debug": false, "titleCommon": *config["titleCommon"] + "Whut?", "logintemplate": true, })) config.LogWarn("No form data posted for password reset") return } /* // Commented out because it exposes sensitive data on the logs; flagged by GitHub's CodeQL (gwyneth 20211116). config.LogDebugf("aPasswordReset: %+v", aPasswordReset) */ // check if this email address is in the database if *config["dsn"] == "" { config.LogFatal("Please configure the DSN for accessing your OpenSimulator database; this application won't work without that") } db, err := sql.Open("mysql", *config["dsn"]) // presumes mysql for now checkErrFatal(err) defer db.Close() var principalID, email, firstName, lastName string err = db.QueryRow("SELECT PrincipalID, Email, FirstName, LastName FROM UserAccounts WHERE Email = ?", aPasswordReset.Email).Scan(&principalID, &email, &firstName, &lastName) // there can be only one, or our database is corrupted if err != nil { // db.QueryRow() will return ErrNoRows, which will be passed to Scan() config.LogDebugf("email address %q not in database, but we're not telling. Error was: %v", aPasswordReset.Email, err) } config.LogDebugf("Password reset: We have email %q (empty means: not in database) from user %q [UUID %q] (empty means: not in database)", email, firstName+" "+lastName, principalID) if principalID != "" && email != "" { // let's test our KV store by pushing some garbage into it! // TODO(gwyneth): this has to be done much more carefully... // Use Cryptographically Secure Randomly Generated Split-Tokens as shown on the P.I.E. algorithm found here https://paragonie.com/blog/2016/09/untangling-forget-me-knot-secure-account-recovery-made-simple#secure-password-reset-tokens (gwyneth 20200706) selector := randomBase64String(15) verifier := randomBase64String(18) sha256 := sha256.Sum256([]byte(verifier)) GOSWIstore.Set(selector, ResetPasswordTokens{ UserUUID: principalID, Username: firstName + " " + lastName, Email: email, Verifier: sha256, Timestamp: time.Now(), }) if *config["ginMode"] == "debug" { var someTokens ResetPasswordTokens found, err := GOSWIstore.Get(selector, &someTokens) if err == nil { if found { config.LogDebugf("What we just stored for selector %q: %+v\n", selector, someTokens) } else { config.LogDebug(selector, "not found in store") } } else { config.LogWarn("Nothing stored for", selector, "error was", err) } } // Now send email! // using example from https://riptutorial.com/go/example/20761/sending-email-with-smtp-sendmail-- (gwyneth 20200706) if email != "" { config.LogDebug(bluemondaySafeHTML.Sanitize(fmt.Sprintf("Request: %+v\n", c.Request))) // Build the actual URL for token scheme := "https:" if c.Request.TLS == nil { scheme = "http:" } tokenURL := scheme + "//" + c.Request.Host + "/user/token/" + selector + verifier // The grid manager's email is stored in *config["gOSWIemail"] // // server we are authorised to send email through is stored in *config["SMTPhost"] // // Create the authentication for the SendMail() // using PlainText, but other authentication methods are encouraged auth := smtp.PlainAuth("", *config["gOSWIemail"], *config["gOSWIpassword"], *config["SMTPhost"]) // NOTE: Using the backtick here ` works like a heredoc, which is why all the // rest of the lines are forced to the beginning of the line, otherwise the // formatting is wrong for the RFC 822 style // TODO(gwyneth): use a template instead? message := `To: "` + firstName + " " + lastName + `" <` + email + `> From: "` + *config["author"] + `" <` + *config["gOSWIemail"] + `> Subject: Password reset link Someone asked for your password to be reset. If it was you, use the following link: ` + tokenURL + ` ` config.LogDebugf("Message to be sent: %q\n", message) if err := smtp.SendMail(*config["SMTPhost"]+":25", auth, *config["gOSWIemail"], []string{email}, []byte(message)); err != nil { config.LogErrorf("Sending reset link email to <%s> via SMTP host %q failed: %v\n", email, *config["SMTPhost"], err) //os.Exit(1) } config.LogInfo("Success in sending reset link to", email) } } c.HTML(http.StatusOK, "reset-password-confirmation.tpl", environment(c, gin.H{ "Content": fmt.Sprintf("Please check your email address %q for a password reset link; if your email address is in our database, you should get it shortly.", email), "Debug": false, "titleCommon": *config["titleCommon"] + "Email sent!", "logintemplate": true, })) } // checkTokenForPasswordReset is called when the user clicks the link for resetting their password, and we need to check if the token is a valid token to allow authentication func checkTokenForPasswordReset(c *gin.Context) { var token string var params Token if err := c.ShouldBindUri(¶ms); err != nil { // this should never fail, but... (gwyneth 20200713) c.HTML(http.StatusNotFound, "404.tpl", environment(c, gin.H{ "titleCommon": *config["titleCommon"] + " - 404", "errorcode": "404", "errortext": "Token not sent", "errorbody": fmt.Sprintf("Invalid token or token not sent. Error was: %v", err), })) config.LogError("Invalid token or token not sent. Error was:", err) return } // assign token with the content of the parameter... token = params.Payload if token == "" { // one of those errors that should never happen... (gwyneth 20200713) c.HTML(http.StatusNotFound, "404.tpl", environment(c, gin.H{ "titleCommon": *config["titleCommon"] + " - 404", "errorcode": "404", "errortext": "Empty token payload", "errorbody": "Invalid token or empty token payload.", })) config.LogError("Invalid token or empty token payload.") return } // split token selector := token[:15] verifier := token[15:] sha256 := sha256.Sum256([]byte(verifier)) config.LogDebugf("Got token %q, this is selector %q and verifier %q and SHA256 %q\n", token, selector, verifier, sha256) var someTokens ResetPasswordTokens found, err := GOSWIstore.Get(selector, &someTokens) if err == nil { if found { config.LogInfof("What we just stored for selector %q: %+v", selector, someTokens) // check if it is still valid if time.Since(someTokens.Timestamp) < (2 * time.Hour) { // valid, log user in, move to password change template if subtle.ConstantTimeCompare(sha256[:], someTokens.Verifier[:]) == 1 { config.LogInfof("Token valid for user %q (%q)!", someTokens.Username, someTokens.UserUUID) // user authenticated, get them a session cookie! (gwyneth 20200712) session := sessions.Default(c) session.Set("Username", someTokens.Username) session.Set("UUID", someTokens.UserUUID) session.Set("Token", generateSessionToken()) config.LogDebugf("Password change link: User valid with username: %q UUID: %s Email: <%s> Token: %s", someTokens.Username, someTokens.UserUUID, someTokens.Email, session.Get("Token")) // Prepare for panic with Libravatar defer func() { if r := recover(); r != nil { config.LogErrorf("Libravatar did not return anything: %v\n", r) // clean up and exit session.Set("Libravatar", "") session.Save() // move user to password change template c.HTML(http.StatusOK, "change-password.tpl", environment(c, gin.H{ "Debug": false, "titleCommon": *config["titleCommon"] + "New password!", "logintemplate": true, "someTokens": someTokens, // this will allow us to ignore the 'old' password and just ask for new ones. })) return } }() session.Set("Libravatar", getLibravatar(someTokens.Email, someTokens.Username, 60)) if someTokens.Email != "" { session.Set("Email", someTokens.Email) // who knows, it might be useful at some point } // session.Set("RememberMe", ???) // we may not be able to set this here (yet) session.Save() // move user to password change template c.HTML(http.StatusOK, "change-password.tpl", environment(c, gin.H{ "Debug": false, "titleCommon": *config["titleCommon"] + "New password!", "logintemplate": true, "someTokens": someTokens, // this will allow us to ignore the 'old' password and just ask for new ones. })) // that's all, folks! return } } else { config.LogError("Token expired!") } } else { config.LogError("", selector, "not found in store") } } else { config.LogError("Nothing stored for", selector, "error was", err) } c.HTML(http.StatusForbidden, "404.tpl", environment(c, gin.H{ "titleCommon": *config["titleCommon"] + " - 403", "errorcode": "403", "errortext": "Token incorrect", "errorbody": fmt.Sprintf("Either your token %q is invalid or it has expired!", token), })) } // isUsernameAvailable simply checks the OpenSimulator database table 'UserAccounts' to see if a user exists with this username; note that OpenSimulator considers the username to have two distinct parts, 'first name' and 'last name'. func isUsernameAvailable(username string) bool { // split username into space-separated fields theUsername := strings.Fields(strings.TrimSpace(username)) // We'll just use the first two avatarFirstName := theUsername[0] avatarLastName := theUsername[1] // BUG(gwyneth): I'm not sure this will work if people 'forget' to place a space between first and last name. // We might need to do a bit more than this. if *config["dsn"] == "" { config.LogFatal("Please configure the DSN for accessing your OpenSimulator database; this application won't work without that") } db, err := sql.Open("mysql", *config["dsn"]) // presumes mysql for now checkErrFatal(err) defer db.Close() var principalID string err = db.QueryRow("SELECT PrincipalID FROM UserAccounts WHERE FirstName = ? AND LastName = ?", avatarFirstName, avatarLastName).Scan(&principalID) // if there is one, this will be nil if err != nil { // db.QueryRow() will return ErrNoRows, which will be passed to Scan() config.LogDebug("user", username, "not in database") return true // true means: username is available! } // no errors, username already exists in the database; it's not available, so we return false return false } // getLibravatar returns a Libravatar, Gravatar, or Unicorn and returns the URL to the image. // The image is then saved to our diskv cache (gwyneth 20200908). // Note: named return value to aid with the panics (gwyneth 20250829). func getLibravatar(email string, username string, size uint) (hashedAvatarURL string) { avt := libravatar.New() avt.SetAvatarSize(size) avt.SetUseHTTPS(true) var ( avatarURL string // URL for external service; placed here for scope reasons. // TODO(gwyneth): We cannot be sure that the extension is valid // imageExtension string = *config["convertExt"] // may be wrong, but this is our fallback ) // TODO(gwyneth): First see if we have a profile image for this user. If not, we'll get a Gravatar/Libravatar. // Even better will be to _implement_ Libravatar for OpenSim profiles! :-) (gwyneth 20200814) if email != "" { var err error if avatarURL, err = avt.FromEmail(email); err != nil { config.LogDebug("getLibravatar returned error:", err) // couldn't get an image url from the Libravatar service, so get an Unicorn instead! avatarURL = fmt.Sprintf("https://unicornify.pictures/avatar/%s?s=%d", GetMD5Hash(email), size) } } else { // if we don't have a valid email, get an Unicorn! config.LogDebug("getLibravatar(): Empty email on database, attempting to get a Unicorn via username") avatarURL = fmt.Sprintf("https://unicornify.pictures/avatar/%s?s=%d", GetMD5Hash(username), size) // Note that this may also fail... } // Now see if we have this URL in the KV store (cache). For key we don't use the URL directly because the actual URL may have weird characters; instead we use a simple MD5 hash: hashedAvatarURL = filepath.Join(*config["cache"], GetMD5Hash(avatarURL)) if !imageCache.Has(hashedAvatarURL) { // Not in the cache; so we will download it and place it in the KV store if all went well. config.LogDebug("getLibravatar(): Cache miss on Gravatar/Libravatar URL:", avatarURL, " - attempting to download it...") // Not in the KV store yet, so we dial out to get it from the avatar image provider (whatever it might be). // Note: we want to follow redirects (default policy) and extract the content type of what gets returned resp, err := http.Get(avatarURL) if err != nil { // handle error, could be a timeout hashedAvatarURL = "" config.LogPanic("Oops — getLibravatar cannot find", avatarURL, " - error was:", err) } defer resp.Body.Close() newImage, err := io.ReadAll(resp.Body) // deprecated if err != nil { hashedAvatarURL = "" config.LogPanic("Oops — could not get image contents of", avatarURL, "from getLibravatar, error was:", err) } if len(newImage) == 0 { hashedAvatarURL = "" config.LogPanic("Image retrieved from getLibravatar", avatarURL, "has zero bytes.") // we might have to get out of here } else { config.LogDebug("Image retrieved from getLibravatar", avatarURL, "has", len(newImage), "bytes.") } /* // TODO(gwyneth): This needs rethinking... (gwyneth 20200814) // At this point, we know that the avatarURL is valid and has retrieved a valid image // Let's check what type the image has: rawContentType := resp.Header.Get("Content-Type") if allPossibleExtensions, err := mime.ExtensionsByType(rawContentType); err == nil { if allPossibleExtensions != nil { // this means that there are no extensions associated with this content-type imageExtension = allPossibleExtensions[0] // we just need the first one } else { config.LogDebugf("getLibravatar(): unknown content-type %q for %q\n", rawContentType, avatarURL) } } else { config.LogDebugf("getLibravatar(): error retrieving content-type %q for %q\n", rawContentType, avatarURL) } */ // just a final debugging check before actually writing things to the KV store config.LogDebug("getLibravatar(): avatarURL is", avatarURL, "while hash is", hashedAvatarURL) if err := imageCache.Write(hashedAvatarURL, newImage); err != nil { hashedAvatarURL = "" config.LogPanic(" getLibravatar(): Could not store", avatarURL, "in the cache, error was:", err) } } // If we haven't panickd by now, assemble the path to return to user; from now on, this is a static image residing in _our_ filesystem! // return filepath.Join(PathToStaticFiles, hashedAvatarURL /* + imageExtension */) return hashedAvatarURL }