This repository has been archived on 2022-08-26. You can view files and clone it, but cannot push or open issues or pull requests.
gosocial/pkg/controller/account/settings.go
Noah Petherbridge 0caf12eb00 User Account Busywork
* Add "forgot password" workflow.
* Add ability to change user email address (confirmation link sent)
* Add ability to change user's password.
* Add rate limiter to deter brute force login attempts.
* Add user deep delete functionality (delete account).
* Ping user LastLoginAt every 8 hours for long-lived session cookies.
* Add age filters to user search page.
* Add sort options to user search (last login, created, username/name)
2022-08-14 14:40:57 -07:00

246 lines
7.5 KiB
Go

package account
import (
"fmt"
"net/http"
nm "net/mail"
"strings"
"time"
"git.kirsle.net/apps/gosocial/pkg/config"
"git.kirsle.net/apps/gosocial/pkg/log"
"git.kirsle.net/apps/gosocial/pkg/mail"
"git.kirsle.net/apps/gosocial/pkg/models"
"git.kirsle.net/apps/gosocial/pkg/redis"
"git.kirsle.net/apps/gosocial/pkg/session"
"git.kirsle.net/apps/gosocial/pkg/templates"
"git.kirsle.net/apps/gosocial/pkg/utility"
"github.com/google/uuid"
)
// ChangeEmailToken for Redis.
type ChangeEmailToken struct {
Token string
UserID uint64
NewEmail string
}
// Delete the change email token.
func (t ChangeEmailToken) Delete() error {
return redis.Delete(fmt.Sprintf(config.ChangeEmailRedisKey, t.Token))
}
// User settings page. (/settings).
func Settings() http.HandlerFunc {
tmpl := templates.Must("account/settings.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
vars := map[string]interface{}{
"Enum": config.ProfileEnums,
}
// Load the current user in case of updates.
user, err := session.CurrentUser(r)
if err != nil {
session.FlashError(w, r, "Couldn't get CurrentUser: %s", err)
templates.Redirect(w, r.URL.Path)
return
}
// Are we POSTing?
if r.Method == http.MethodPost {
intent := r.PostFormValue("intent")
switch intent {
case "profile":
// Setting profile values.
var (
displayName = r.PostFormValue("display_name")
dob = r.PostFormValue("dob")
)
// Set user attributes.
user.Name = &displayName
if len(dob) > 0 {
if birthdate, err := time.Parse("2006-01-02", dob); err != nil {
session.FlashError(w, r, "Incorrect format for birthdate; should be in yyyy-mm-dd format but got: %s", dob)
} else {
// Validate birthdate is at least age 18.
if utility.Age(birthdate) < 18 {
session.FlashError(w, r, "Invalid birthdate: you must be at least 18 years old to use this site.")
templates.Redirect(w, r.URL.Path)
return
}
user.Birthdate = birthdate
}
} else {
user.Birthdate = time.Time{}
}
// Set profile attributes.
for _, attr := range config.ProfileFields {
user.SetProfileField(attr, r.PostFormValue(attr))
}
// "Looking For" checkbox list.
if hereFor, ok := r.PostForm["here_for"]; ok {
user.SetProfileField("here_for", strings.Join(hereFor, ","))
}
if err := user.Save(); err != nil {
session.FlashError(w, r, "Failed to save user to database: %s", err)
}
session.Flash(w, r, "Profile settings updated!")
case "preferences":
var (
explicit = r.PostFormValue("explicit") == "true"
)
user.Explicit = explicit
if err := user.Save(); err != nil {
session.FlashError(w, r, "Failed to save user to database: %s", err)
}
session.Flash(w, r, "Website preferences updated!")
case "settings":
var (
oldPassword = r.PostFormValue("old_password")
changeEmail = strings.TrimSpace(strings.ToLower(r.PostFormValue("change_email")))
password1 = strings.TrimSpace(strings.ToLower(r.PostFormValue("new_password")))
password2 = r.PostFormValue("new_password2")
)
// Their old password is needed to make any changes to their account.
if err := user.CheckPassword(oldPassword); err != nil {
session.FlashError(w, r, "Could not make changes to your account settings as the 'current password' you entered was incorrect.")
templates.Redirect(w, r.URL.Path)
return
}
// Changing their email?
if changeEmail != user.Email {
// Validate the email.
if _, err := nm.ParseAddress(changeEmail); err != nil {
session.FlashError(w, r, "The email address you entered is not valid: %s", err)
templates.Redirect(w, r.URL.Path)
return
}
// Email must not already exist.
if _, err := models.FindUser(changeEmail); err == nil {
session.FlashError(w, r, "That email address is already in use.")
templates.Redirect(w, r.URL.Path)
return
}
// Create a tokenized link.
token := ChangeEmailToken{
Token: uuid.New().String(),
UserID: user.ID,
NewEmail: changeEmail,
}
if err := redis.Set(fmt.Sprintf(config.ChangeEmailRedisKey, token.Token), token, config.SignupTokenExpires); err != nil {
session.FlashError(w, r, "Failed to create change email token: %s", err)
templates.Redirect(w, r.URL.Path)
return
}
err := mail.Send(mail.Message{
To: changeEmail,
Subject: "Verify your e-mail address",
Template: "email/verify_email.html",
Data: map[string]interface{}{
"Title": config.Title,
"URL": config.Current.BaseURL + "/settings/confirm-email?token=" + token.Token,
"ChangeEmail": true,
},
})
if err != nil {
session.FlashError(w, r, "Error sending a confirmation email to %s: %s", changeEmail, err)
} else {
session.Flash(w, r, "Please verify your new email address. A link has been sent to %s to confirm.", changeEmail)
}
}
// Changing their password?
if password1 != "" {
if password2 != password1 {
session.FlashError(w, r, "Couldn't change your password: your new passwords do not match.")
} else {
// Hash the new password.
if err := user.HashPassword(password1); err != nil {
session.FlashError(w, r, "Failed to hash your new password: %s", err)
} else {
// Save the user row.
if err := user.Save(); err != nil {
session.FlashError(w, r, "Failed to update your password in the database: %s", err)
} else {
session.Flash(w, r, "Your password has been updated.")
}
}
}
}
default:
session.FlashError(w, r, "Unknown POST intent value. Please try again.")
}
templates.Redirect(w, r.URL.Path)
return
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
}
// ConfirmEmailChange after a user tries to change their email.
func ConfirmEmailChange() http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var tokenStr = r.FormValue("token")
if tokenStr != "" {
var token ChangeEmailToken
if err := redis.Get(fmt.Sprintf(config.ChangeEmailRedisKey, tokenStr), &token); err != nil {
session.FlashError(w, r, "Invalid token. Please try again to change your email address.")
templates.Redirect(w, "/")
return
}
// Verify new email still doesn't already exist.
if _, err := models.FindUser(token.NewEmail); err == nil {
session.FlashError(w, r, "Couldn't update your email address: it is already in use by another member.")
templates.Redirect(w, "/")
return
}
// Look up the user.
user, err := models.GetUser(token.UserID)
if err != nil {
session.FlashError(w, r, "Didn't find the user that this email change was for. Please try again.")
templates.Redirect(w, "/")
return
}
// Burn the token.
if err := token.Delete(); err != nil {
log.Error("ChangeEmail: couldn't delete Redis token: %s", err)
}
// Make the change.
user.Email = token.NewEmail
if err := user.Save(); err != nil {
session.FlashError(w, r, "Couldn't save the change to your user: %s", err)
} else {
session.Flash(w, r, "Your email address has been confirmed and updated.")
templates.Redirect(w, "/")
}
} else {
session.FlashError(w, r, "Invalid change email token. Please try again.")
}
templates.Redirect(w, "/")
})
}