hmn/src/website/auth.go

805 lines
23 KiB
Go

package website
import (
"errors"
"fmt"
"net/http"
"regexp"
"strings"
"time"
"git.handmade.network/hmn/hmn/src/auth"
"git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/email"
"git.handmade.network/hmn/hmn/src/hmnurl"
"git.handmade.network/hmn/hmn/src/logging"
"git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/oops"
"git.handmade.network/hmn/hmn/src/templates"
)
var UsernameRegex = regexp.MustCompile(`^[0-9a-zA-Z][\w-]{2,29}$`)
type LoginPageData struct {
templates.BaseData
RedirectUrl string
ForgotPasswordUrl string
}
func LoginPage(c *RequestContext) ResponseData {
if c.CurrentUser != nil {
return RejectRequest(c, "You are already logged in.")
}
var res ResponseData
res.MustWriteTemplate("auth_login.html", LoginPageData{
BaseData: getBaseDataAutocrumb(c, "Log in"),
RedirectUrl: c.Req.URL.Query().Get("redirect"),
ForgotPasswordUrl: hmnurl.BuildRequestPasswordReset(),
}, c.Perf)
return res
}
func Login(c *RequestContext) ResponseData {
form, err := c.GetFormValues()
if err != nil {
return c.ErrorResponse(http.StatusBadRequest, NewSafeError(err, "request must contain form data"))
}
redirect := form.Get("redirect")
if redirect == "" {
redirect = "/"
}
if c.CurrentUser != nil {
res := c.Redirect(redirect, http.StatusSeeOther)
res.AddFutureNotice("warn", fmt.Sprintf("You are already logged in as %s.", c.CurrentUser.Username))
return res
}
username := form.Get("username")
password := form.Get("password")
if username == "" || password == "" {
return c.Redirect(hmnurl.BuildLoginPage(redirect), http.StatusSeeOther)
}
showLoginWithFailure := func(c *RequestContext, redirect string) ResponseData {
var res ResponseData
baseData := getBaseDataAutocrumb(c, "Log in")
baseData.AddImmediateNotice("failure", "Incorrect username or password")
res.MustWriteTemplate("auth_login.html", LoginPageData{
BaseData: baseData,
RedirectUrl: redirect,
ForgotPasswordUrl: hmnurl.BuildRequestPasswordReset(),
}, c.Perf)
return res
}
user, err := db.QueryOne[models.User](c.Context(), c.Conn,
`
SELECT $columns
FROM hmn_user
WHERE LOWER(username) = LOWER($1)
`,
username,
)
if err != nil {
if errors.Is(err, db.NotFound) {
return showLoginWithFailure(c, redirect)
} else {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to look up user by username"))
}
}
success, err := tryLogin(c, user, password)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, err)
}
if !success {
return showLoginWithFailure(c, redirect)
}
if user.Status == models.UserStatusInactive {
return RejectRequest(c, "You must validate your email address before logging in. You should've received an email shortly after registration. If you did not receive the email, please contact the staff.")
}
res := c.Redirect(redirect, http.StatusSeeOther)
err = loginUser(c, user, &res)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, err)
}
return res
}
func Logout(c *RequestContext) ResponseData {
redir := c.Req.URL.Query().Get("redirect")
if redir == "" {
redir = "/"
}
res := c.Redirect(redir, http.StatusSeeOther)
logoutUser(c, &res)
return res
}
func RegisterNewUser(c *RequestContext) ResponseData {
if c.CurrentUser != nil {
c.Redirect(hmnurl.BuildUserSettings(c.CurrentUser.Username), http.StatusSeeOther)
}
// TODO(asaf): Do something to prevent bot registration
var res ResponseData
res.MustWriteTemplate("auth_register.html", getBaseDataAutocrumb(c, "Register"), c.Perf)
return res
}
func RegisterNewUserSubmit(c *RequestContext) ResponseData {
if c.CurrentUser != nil {
return RejectRequest(c, "Can't register new user. You are already logged in")
}
c.Req.ParseForm()
username := strings.TrimSpace(c.Req.Form.Get("username"))
displayName := strings.TrimSpace(c.Req.Form.Get("displayname"))
emailAddress := strings.TrimSpace(c.Req.Form.Get("email"))
password := c.Req.Form.Get("password")
password2 := c.Req.Form.Get("password2")
if !UsernameRegex.Match([]byte(username)) {
return RejectRequest(c, "Invalid username")
}
if !email.IsEmail(emailAddress) {
return RejectRequest(c, "Invalid email address")
}
if len(password) < 8 {
return RejectRequest(c, "Password too short")
}
if password != password2 {
return RejectRequest(c, "Password confirmation doesn't match password")
}
c.Perf.StartBlock("SQL", "Check blacklist")
// TODO(asaf): Check email against blacklist
blacklisted := false
if blacklisted {
// NOTE(asaf): Silent rejection so we don't allow attackers to harvest emails.
return c.Redirect(hmnurl.BuildRegistrationSuccess(), http.StatusSeeOther)
}
c.Perf.EndBlock()
c.Perf.StartBlock("SQL", "Check for existing usernames and emails")
userAlreadyExists := true
_, err := db.QueryOneScalar[int](c.Context(), c.Conn,
`
SELECT id
FROM hmn_user
WHERE LOWER(username) = LOWER($1)
`,
username,
)
if err != nil {
if errors.Is(err, db.NotFound) {
userAlreadyExists = false
} else {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user"))
}
}
if userAlreadyExists {
return RejectRequest(c, fmt.Sprintf("Username (%s) already exists.", username))
}
emailAlreadyExists := true
_, err = db.QueryOneScalar[int](c.Context(), c.Conn,
`
SELECT id
FROM hmn_user
WHERE LOWER(email) = LOWER($1)
`,
emailAddress,
)
if err != nil {
if errors.Is(err, db.NotFound) {
emailAlreadyExists = false
} else {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user"))
}
}
c.Perf.EndBlock()
if emailAlreadyExists {
// NOTE(asaf): Silent rejection so we don't allow attackers to harvest emails.
return c.Redirect(hmnurl.BuildRegistrationSuccess(), http.StatusSeeOther)
}
hashed := auth.HashPassword(password)
c.Perf.StartBlock("SQL", "Create user and one time token")
tx, err := c.Conn.Begin(c.Context())
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to start db transaction"))
}
defer tx.Rollback(c.Context())
now := time.Now()
var newUserId int
err = tx.QueryRow(c.Context(),
`
INSERT INTO hmn_user (username, email, password, date_joined, name, registration_ip)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id
`,
username, emailAddress, hashed.String(), now, displayName, c.GetIP(),
).Scan(&newUserId)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to store user"))
}
ott := models.GenerateToken()
_, err = tx.Exec(c.Context(),
`
INSERT INTO one_time_token (token_type, created, expires, token_content, owner_id)
VALUES($1, $2, $3, $4, $5)
`,
models.TokenTypeRegistration,
now,
now.Add(time.Hour*24*7),
ott,
newUserId,
)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to store one-time token"))
}
c.Perf.EndBlock()
mailName := displayName
if mailName == "" {
mailName = username
}
err = email.SendRegistrationEmail(emailAddress, mailName, username, ott, c.Perf)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to send registration email"))
}
c.Perf.StartBlock("SQL", "Commit user")
err = tx.Commit(c.Context())
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to commit user to the db"))
}
c.Perf.EndBlock()
return c.Redirect(hmnurl.BuildRegistrationSuccess(), http.StatusSeeOther)
}
type RegisterNewUserSuccessData struct {
templates.BaseData
ContactUsUrl string
}
func RegisterNewUserSuccess(c *RequestContext) ResponseData {
if c.CurrentUser != nil {
return c.Redirect(hmnurl.BuildHomepage(), http.StatusSeeOther)
}
var res ResponseData
res.MustWriteTemplate("auth_register_success.html", RegisterNewUserSuccessData{
BaseData: getBaseDataAutocrumb(c, "Register"),
ContactUsUrl: hmnurl.BuildContactPage(),
}, c.Perf)
return res
}
type EmailValidationData struct {
templates.BaseData
Token string
Username string
}
func EmailConfirmation(c *RequestContext) ResponseData {
if c.CurrentUser != nil {
return c.Redirect(hmnurl.BuildHomepage(), http.StatusSeeOther)
}
username, hasUsername := c.PathParams["username"]
if !hasUsername {
return RejectRequest(c, "Bad validation url")
}
token := ""
hasToken := false
// TODO(asaf): Delete old hash/nonce about a week after launch
hash, hasHash := c.PathParams["hash"]
nonce, hasNonce := c.PathParams["nonce"]
if hasHash && hasNonce {
token = fmt.Sprintf("%s/%s", hash, nonce)
hasToken = true
} else {
token, hasToken = c.PathParams["token"]
}
if !hasToken {
return RejectRequest(c, "Bad validation url")
}
validationResult := validateUsernameAndToken(c, username, token, models.TokenTypeRegistration)
if !validationResult.Match {
return makeResponseForBadRegistrationTokenValidationResult(c, validationResult)
}
var res ResponseData
res.MustWriteTemplate("auth_email_validation.html", EmailValidationData{
BaseData: getBaseDataAutocrumb(c, "Register"),
Token: token,
Username: username,
}, c.Perf)
return res
}
func EmailConfirmationSubmit(c *RequestContext) ResponseData {
c.Req.ParseForm()
token := c.Req.Form.Get("token")
username := c.Req.Form.Get("username")
password := c.Req.Form.Get("password")
validationResult := validateUsernameAndToken(c, username, token, models.TokenTypeRegistration)
if !validationResult.Match {
return makeResponseForBadRegistrationTokenValidationResult(c, validationResult)
}
success, err := tryLogin(c, validationResult.User, password)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, err)
} else if !success {
var res ResponseData
baseData := getBaseDataAutocrumb(c, "Register")
// NOTE(asaf): We can report that the password is incorrect, because an attacker wouldn't have a valid token to begin with.
baseData.AddImmediateNotice("failure", "Incorrect password. Please try again.")
res.MustWriteTemplate("auth_email_validation.html", EmailValidationData{
BaseData: baseData,
Token: token,
Username: username,
}, c.Perf)
return res
}
c.Perf.StartBlock("SQL", "Updating user status and deleting token")
tx, err := c.Conn.Begin(c.Context())
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to start db transaction"))
}
defer tx.Rollback(c.Context())
_, err = tx.Exec(c.Context(),
`
UPDATE hmn_user
SET status = $1
WHERE id = $2
`,
models.UserStatusConfirmed,
validationResult.User.ID,
)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to update user status"))
}
_, err = tx.Exec(c.Context(),
`
DELETE FROM one_time_token WHERE id = $1
`,
validationResult.OneTimeToken.ID,
)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to delete one time token"))
}
err = tx.Commit(c.Context())
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to commit transaction"))
}
c.Perf.EndBlock()
res := c.Redirect(hmnurl.BuildHomepage(), http.StatusSeeOther)
res.AddFutureNotice("success", "You've completed your registration successfully!")
err = loginUser(c, validationResult.User, &res)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, err)
}
return res
}
// NOTE(asaf): Only call this when validationResult.Match is false.
func makeResponseForBadRegistrationTokenValidationResult(c *RequestContext, validationResult validateUserAndTokenResult) ResponseData {
if validationResult.User == nil {
return RejectRequest(c, "You haven't validated your email in time and your user was deleted. You may try registering again with the same username.")
}
if validationResult.OneTimeToken == nil {
// NOTE(asaf): The user exists, but the validation token doesn't.
// That means the user already validated their email and can just log in normally.
return c.Redirect(hmnurl.BuildLoginPage(""), http.StatusSeeOther)
}
return RejectRequest(c, "Bad token. If you are having problems registering or logging in, please contact the staff.")
}
// NOTE(asaf): PasswordReset refers specifically to "forgot your password" flow over email,
// not to changing your password through the user settings page.
func RequestPasswordReset(c *RequestContext) ResponseData {
if c.CurrentUser != nil {
return c.Redirect(hmnurl.BuildHomepage(), http.StatusSeeOther)
}
var res ResponseData
res.MustWriteTemplate("auth_password_reset.html", getBaseDataAutocrumb(c, "Password Reset"), c.Perf)
return res
}
func RequestPasswordResetSubmit(c *RequestContext) ResponseData {
if c.CurrentUser != nil {
return c.Redirect(hmnurl.BuildHomepage(), http.StatusSeeOther)
}
c.Req.ParseForm()
username := strings.TrimSpace(c.Req.Form.Get("username"))
emailAddress := strings.TrimSpace(c.Req.Form.Get("email"))
if username == "" && emailAddress == "" {
return RejectRequest(c, "You must provide a username and an email address.")
}
c.Perf.StartBlock("SQL", "Fetching user")
type userQuery struct {
User models.User `db:"hmn_user"`
}
user, err := db.QueryOne[models.User](c.Context(), c.Conn,
`
SELECT $columns
FROM hmn_user
WHERE
LOWER(username) = LOWER($1)
AND LOWER(email) = LOWER($2)
`,
username,
emailAddress,
)
c.Perf.EndBlock()
if err != nil {
if !errors.Is(err, db.NotFound) {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to look up user by username"))
}
}
if user != nil {
c.Perf.StartBlock("SQL", "Fetching existing token")
resetToken, err := db.QueryOne[models.OneTimeToken](c.Context(), c.Conn,
`
SELECT $columns
FROM one_time_token
WHERE
token_type = $1
AND owner_id = $2
`,
models.TokenTypePasswordReset,
user.ID,
)
c.Perf.EndBlock()
if err != nil {
if !errors.Is(err, db.NotFound) {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch onetimetoken for user"))
}
}
now := time.Now()
if resetToken != nil {
if resetToken.Expires.Before(now.Add(time.Minute * 30)) { // NOTE(asaf): Expired or about to expire
c.Perf.StartBlock("SQL", "Deleting expired token")
_, err = c.Conn.Exec(c.Context(),
`
DELETE FROM one_time_token
WHERE id = $1
`,
resetToken.ID,
)
c.Perf.EndBlock()
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to delete onetimetoken"))
}
resetToken = nil
}
}
if resetToken == nil {
c.Perf.StartBlock("SQL", "Creating new token")
newToken, err := db.QueryOne[models.OneTimeToken](c.Context(), c.Conn,
`
INSERT INTO one_time_token (token_type, created, expires, token_content, owner_id)
VALUES ($1, $2, $3, $4, $5)
RETURNING $columns
`,
models.TokenTypePasswordReset,
now,
now.Add(time.Hour*24),
models.GenerateToken(),
user.ID,
)
c.Perf.EndBlock()
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to create onetimetoken"))
}
resetToken = newToken
err = email.SendPasswordReset(user.Email, user.BestName(), user.Username, resetToken.Content, resetToken.Expires, c.Perf)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to send email"))
}
}
}
return c.Redirect(hmnurl.BuildPasswordResetSent(), http.StatusSeeOther)
}
type PasswordResetSentData struct {
templates.BaseData
ContactUsUrl string
}
func PasswordResetSent(c *RequestContext) ResponseData {
if c.CurrentUser != nil {
return c.Redirect(hmnurl.BuildHomepage(), http.StatusSeeOther)
}
var res ResponseData
res.MustWriteTemplate("auth_password_reset_sent.html", PasswordResetSentData{
BaseData: getBaseDataAutocrumb(c, "Password Reset"),
ContactUsUrl: hmnurl.BuildContactPage(),
}, c.Perf)
return res
}
type DoPasswordResetData struct {
templates.BaseData
Username string
Token string
}
func DoPasswordReset(c *RequestContext) ResponseData {
username, hasUsername := c.PathParams["username"]
token, hasToken := c.PathParams["token"]
if !hasToken || !hasUsername {
return RejectRequest(c, "Bad validation url.")
}
validationResult := validateUsernameAndToken(c, username, token, models.TokenTypePasswordReset)
if !validationResult.Match {
return RejectRequest(c, "Bad validation url.")
}
var res ResponseData
if c.CurrentUser != nil && c.CurrentUser.ID != validationResult.User.ID {
// NOTE(asaf): In the rare case that a user is logged in with user A and is trying to
// change the password for user B, log out the current user to avoid confusion.
logoutUser(c, &res)
}
res.MustWriteTemplate("auth_do_password_reset.html", DoPasswordResetData{
BaseData: getBaseDataAutocrumb(c, "Password Reset"),
Username: username,
Token: token,
}, c.Perf)
return res
}
func DoPasswordResetSubmit(c *RequestContext) ResponseData {
c.Req.ParseForm()
token := c.Req.Form.Get("token")
username := c.Req.Form.Get("username")
password := c.Req.Form.Get("password")
password2 := c.Req.Form.Get("password2")
validationResult := validateUsernameAndToken(c, username, token, models.TokenTypePasswordReset)
if !validationResult.Match {
return RejectRequest(c, "Bad validation url.")
}
if c.CurrentUser != nil && c.CurrentUser.ID != validationResult.User.ID {
return RejectRequest(c, fmt.Sprintf("Can't change password for %s. You are logged in as %s.", username, c.CurrentUser.Username))
}
if len(password) < 8 {
return RejectRequest(c, "Password too short")
}
if password != password2 {
return RejectRequest(c, "Password confirmation doesn't match password")
}
hashed := auth.HashPassword(password)
c.Perf.StartBlock("SQL", "Update user's password and delete reset token")
tx, err := c.Conn.Begin(c.Context())
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to start db transaction"))
}
defer tx.Rollback(c.Context())
tag, err := tx.Exec(c.Context(),
`
UPDATE hmn_user
SET password = $1
WHERE id = $2
`,
hashed.String(),
validationResult.User.ID,
)
if err != nil || tag.RowsAffected() == 0 {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to update user's password"))
}
if validationResult.User.Status == models.UserStatusInactive {
_, err = tx.Exec(c.Context(),
`
UPDATE hmn_user
SET status = $1
WHERE id = $2
`,
models.UserStatusConfirmed,
validationResult.User.ID,
)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to update user's status"))
}
}
_, err = tx.Exec(c.Context(),
`
DELETE FROM one_time_token
WHERE id = $1
`,
validationResult.OneTimeToken.ID,
)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to delete onetimetoken"))
}
err = tx.Commit(c.Context())
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to commit password reset to the db"))
}
c.Perf.EndBlock()
res := c.Redirect(hmnurl.BuildUserSettings(""), http.StatusSeeOther)
res.AddFutureNotice("success", "Password changed successfully.")
err = loginUser(c, validationResult.User, &res)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, err)
}
return res
}
func tryLogin(c *RequestContext, user *models.User, password string) (bool, error) {
c.Perf.StartBlock("AUTH", "Checking password")
defer c.Perf.EndBlock()
hashed, err := auth.ParsePasswordString(user.Password)
if err != nil {
return false, oops.New(err, "failed to parse password string")
}
passwordsMatch, err := auth.CheckPassword(password, hashed)
if err != nil {
return false, oops.New(err, "failed to check password against hash")
}
if !passwordsMatch {
return false, nil
}
// re-hash and save the user's password if necessary
if hashed.IsOutdated() {
newHashed := auth.HashPassword(password)
err := auth.UpdatePassword(c.Context(), c.Conn, user.Username, newHashed)
if err != nil {
c.Logger.Error().Err(err).Msg("failed to update user's password")
}
// If errors happen here, we can still continue with logging them in
}
return true, nil
}
func loginUser(c *RequestContext, user *models.User, responseData *ResponseData) error {
c.Perf.StartBlock("SQL", "Setting last login and creating session")
defer c.Perf.EndBlock()
tx, err := c.Conn.Begin(c.Context())
if err != nil {
return oops.New(err, "failed to start db transaction")
}
defer tx.Rollback(c.Context())
now := time.Now()
_, err = tx.Exec(c.Context(),
`
UPDATE hmn_user
SET last_login = $1
WHERE id = $2
`,
now,
user.ID,
)
if err != nil {
return oops.New(err, "failed to update last_login for user")
}
session, err := auth.CreateSession(c.Context(), c.Conn, user.Username)
if err != nil {
return oops.New(err, "failed to create session")
}
err = tx.Commit(c.Context())
if err != nil {
return oops.New(err, "failed to commit transaction")
}
responseData.SetCookie(auth.NewSessionCookie(session))
return nil
}
func logoutUser(c *RequestContext, res *ResponseData) {
sessionCookie, err := c.Req.Cookie(auth.SessionCookieName)
if err == nil {
// clear the session from the db immediately, no expiration
err := auth.DeleteSession(c.Context(), c.Conn, sessionCookie.Value)
if err != nil {
logging.Error().Err(err).Msg("failed to delete session on logout")
}
}
res.SetCookie(auth.DeleteSessionCookie)
}
type validateUserAndTokenResult struct {
User *models.User
OneTimeToken *models.OneTimeToken
Match bool
Error error
}
func validateUsernameAndToken(c *RequestContext, username string, token string, tokenType models.OneTimeTokenType) validateUserAndTokenResult {
c.Perf.StartBlock("SQL", "Check username and token")
defer c.Perf.EndBlock()
type userAndTokenQuery struct {
User models.User `db:"hmn_user"`
OneTimeToken *models.OneTimeToken `db:"onetimetoken"`
}
data, err := db.QueryOne[userAndTokenQuery](c.Context(), c.Conn,
`
SELECT $columns
FROM hmn_user
LEFT JOIN asset AS hmn_user_avatar ON hmn_user_avatar.id = hmn_user.avatar_asset_id
LEFT JOIN one_time_token AS onetimetoken ON onetimetoken.owner_id = hmn_user.id
WHERE
LOWER(hmn_user.username) = LOWER($1)
AND onetimetoken.token_type = $2
`,
username,
tokenType,
)
var result validateUserAndTokenResult
if err != nil {
if !errors.Is(err, db.NotFound) {
result.Error = oops.New(err, "failed to fetch user and token from db")
return result
}
}
if data != nil {
result.User = &data.User
result.OneTimeToken = data.OneTimeToken
if result.OneTimeToken != nil {
result.Match = (result.OneTimeToken.Content == token)
}
}
return result
}