website/pkg/controller/account/signup.go
Noah Petherbridge 1ee8acf060 Various quick fixes
* Signup: if entering an existing email, don't admit that the email
  exists. Instead, send a specialized email to its address.
* Search: no longer search for users by email address.
* Login: always hash the incoming password on user not found, to take
  constant time compared to when the user did exist.
* Fix a pagination bug when a private (shy account) views a non-friend's
  photo gallery.
2023-08-15 17:33:33 -07:00

248 lines
7.7 KiB
Go

package account
import (
"fmt"
"net/http"
nm "net/mail"
"strings"
"time"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/mail"
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/redis"
"code.nonshy.com/nonshy/website/pkg/session"
"code.nonshy.com/nonshy/website/pkg/templates"
"code.nonshy.com/nonshy/website/pkg/utility"
"github.com/google/uuid"
)
// SignupToken goes in Redis when the user first gives us their email address. They
// verify their email before signing up, so cache only in Redis until verified.
type SignupToken struct {
Email string
Token string
}
// Delete a SignupToken when it's been used up.
func (st SignupToken) Delete() error {
return redis.Delete(fmt.Sprintf(config.SignupTokenRedisKey, st.Token))
}
// Initial signup controller.
func Signup() http.HandlerFunc {
tmpl := templates.Must("account/signup.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Template vars.
var vars = map[string]interface{}{
"SignupToken": "", // non-empty if user has clicked verification link
"SkipEmailVerification": false, // true if email verification is disabled
"Email": "", // pre-filled user email
}
// Is email verification disabled?
if config.SkipEmailVerification {
vars["SkipEmailVerification"] = true
}
// Are we called with an email verification token?
var tokenStr = r.URL.Query().Get("token")
if r.Method == http.MethodPost {
tokenStr = r.PostFormValue("token")
}
var token SignupToken
log.Info("SignupToken: %s", tokenStr)
if tokenStr != "" {
// Validate it.
if err := redis.Get(fmt.Sprintf(config.SignupTokenRedisKey, tokenStr), &token); err != nil || token.Token != tokenStr {
session.FlashError(w, r, "Invalid email verification token. Please try signing up again.")
templates.Redirect(w, r.URL.Path)
return
}
vars["SignupToken"] = tokenStr
vars["Email"] = token.Email
}
log.Info("Vars: %+v", vars)
// Posting?
if r.Method == http.MethodPost {
var (
// Collect form fields.
email = strings.TrimSpace(strings.ToLower(r.PostFormValue("email")))
confirm = r.PostFormValue("confirm") == "true"
// Only on full signup form
username = strings.TrimSpace(strings.ToLower(r.PostFormValue("username")))
password = strings.TrimSpace(r.PostFormValue("password"))
password2 = strings.TrimSpace(r.PostFormValue("password2"))
dob = r.PostFormValue("dob")
)
// Don't let them sneakily change their verified email address on us.
if vars["SignupToken"] != "" && email != vars["Email"] {
session.FlashError(w, r, "This email address is not verified. Please start over from the beginning.")
templates.Redirect(w, r.URL.Path)
return
}
// Reserved username check.
for _, cmp := range config.ReservedUsernames {
if username == cmp {
session.FlashError(w, r, "That username is reserved, please choose a different username.")
templates.Redirect(w, r.URL.Path+"?token="+tokenStr)
return
}
}
// Cache username in case of passwd validation errors.
vars["Email"] = email
vars["Username"] = username
// Is the app not configured to send email?
if !config.Current.Mail.Enabled {
session.FlashError(w, r, "This app is not configured to send email so you can not sign up at this time. "+
"Please contact the website administrator about this issue!")
templates.Redirect(w, r.URL.Path)
return
}
// Validate the email.
if _, err := nm.ParseAddress(email); err != nil {
session.FlashError(w, r, "The email address you entered is not valid: %s", err)
templates.Redirect(w, r.URL.Path)
return
}
// Didn't confirm?
if !confirm {
session.FlashError(w, r, "Confirm that you have read the rules.")
templates.Redirect(w, r.URL.Path)
return
}
// Already an account?
if _, err := models.FindUser(email); err == nil {
// We don't want to admit that the email already is registered, so send an email to the
// address in case the user legitimately forgot, but flash the regular success message.
err := mail.Send(mail.Message{
To: email,
Subject: "You already have a nonshy account",
Template: "email/already_signed_up.html",
Data: map[string]interface{}{
"Title": config.Title,
"URL": config.Current.BaseURL + "/forgot-password",
},
})
if err != nil {
session.FlashError(w, r, "Error sending an email: %s", err)
}
session.Flash(w, r, "We have sent an e-mail to %s with a link to continue signing up your account. Please go and check your e-mail.", email)
templates.Redirect(w, r.URL.Path)
return
}
// Email verification step!
if !config.SkipEmailVerification && vars["SignupToken"] == "" {
// Create a SignupToken verification link to send to their inbox.
token = SignupToken{
Email: email,
Token: uuid.New().String(),
}
if err := redis.Set(fmt.Sprintf(config.SignupTokenRedisKey, token.Token), token, config.SignupTokenExpires); err != nil {
session.FlashError(w, r, "Error creating a link to send you: %s", err)
}
err := mail.Send(mail.Message{
To: email,
Subject: "Verify your e-mail address",
Template: "email/verify_email.html",
Data: map[string]interface{}{
"Title": config.Title,
"URL": config.Current.BaseURL + "/signup?token=" + token.Token,
},
})
if err != nil {
session.FlashError(w, r, "Error sending an email: %s", err)
}
session.Flash(w, r, "We have sent an e-mail to %s with a link to continue signing up your account. Please go and check your e-mail.", email)
templates.Redirect(w, r.URL.Path)
return
}
// DOB check.
birthdate, err := time.Parse("2006-01-02", dob)
if err != nil {
session.FlashError(w, r, "Incorrect format for birthdate; should be in yyyy-mm-dd format but got: %s", dob)
templates.Redirect(w, r.URL.Path)
return
} else {
// Validate birthdate is at least age 18.
if utility.Age(birthdate) < 18 {
session.FlashError(w, r, "You must be at least 18 years old to use this site.")
templates.Redirect(w, "/")
// Burn the signup token.
if token.Token != "" {
if err := token.Delete(); err != nil {
log.Error("SignupToken.Delete(%s): %s", token.Token, err)
}
}
return
}
}
// Full sign-up step (w/ email verification token), validate more things.
var hasError bool
if len(password) < 3 {
session.FlashError(w, r, "Please enter a password longer than 3 characters.")
hasError = true
} else if password != password2 {
session.FlashError(w, r, "Your passwords do not match.")
hasError = true
}
if !config.UsernameRegexp.MatchString(username) {
session.FlashError(w, r, "Your username must consist of only numbers, letters, - . and be 3-32 characters.")
hasError = true
}
// Looking good?
if !hasError {
user, err := models.CreateUser(username, email, password)
if err != nil {
session.FlashError(w, r, err.Error())
} else {
session.Flash(w, r, "User account created. Now logged in as %s.", user.Username)
// Burn the signup token.
if token.Token != "" {
if err := token.Delete(); err != nil {
log.Error("SignupToken.Delete(%s): %s", token.Token, err)
}
}
// Put their birthdate in.
user.Birthdate = birthdate
user.Save()
// Log in the user and send them to their dashboard.
session.LoginUser(w, r, user)
templates.Redirect(w, "/me")
}
}
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
}