805 lines
23 KiB
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
|
|
}
|
|
|
|
userRow, err := db.QueryOne(c.Context(), c.Conn, models.User{}, "SELECT $columns FROM auth_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"))
|
|
}
|
|
}
|
|
user := userRow.(*models.User)
|
|
|
|
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.QueryInt(c.Context(), c.Conn,
|
|
`
|
|
SELECT id
|
|
FROM auth_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.QueryInt(c.Context(), c.Conn,
|
|
`
|
|
SELECT id
|
|
FROM auth_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 auth_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 handmade_onetimetoken (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 auth_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 handmade_onetimetoken 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.")
|
|
}
|
|
|
|
var user *models.User
|
|
|
|
c.Perf.StartBlock("SQL", "Fetching user")
|
|
userRow, err := db.QueryOne(c.Context(), c.Conn, models.User{},
|
|
`
|
|
SELECT $columns
|
|
FROM auth_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 userRow != nil {
|
|
user = userRow.(*models.User)
|
|
}
|
|
|
|
if user != nil {
|
|
c.Perf.StartBlock("SQL", "Fetching existing token")
|
|
tokenRow, err := db.QueryOne(c.Context(), c.Conn, models.OneTimeToken{},
|
|
`
|
|
SELECT $columns
|
|
FROM handmade_onetimetoken
|
|
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"))
|
|
}
|
|
}
|
|
var resetToken *models.OneTimeToken
|
|
if tokenRow != nil {
|
|
resetToken = tokenRow.(*models.OneTimeToken)
|
|
}
|
|
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 handmade_onetimetoken
|
|
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")
|
|
tokenRow, err := db.QueryOne(c.Context(), c.Conn, models.OneTimeToken{},
|
|
`
|
|
INSERT INTO handmade_onetimetoken (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 = tokenRow.(*models.OneTimeToken)
|
|
|
|
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 auth_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 auth_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 handmade_onetimetoken
|
|
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 auth_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:"auth_user"`
|
|
OneTimeToken *models.OneTimeToken `db:"onetimetoken"`
|
|
}
|
|
row, err := db.QueryOne(c.Context(), c.Conn, userAndTokenQuery{},
|
|
`
|
|
SELECT $columns
|
|
FROM auth_user
|
|
LEFT JOIN handmade_onetimetoken AS onetimetoken ON onetimetoken.owner_id = auth_user.id
|
|
WHERE
|
|
LOWER(auth_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 row != nil {
|
|
data := row.(*userAndTokenQuery)
|
|
result.User = &data.User
|
|
result.OneTimeToken = data.OneTimeToken
|
|
if result.OneTimeToken != nil {
|
|
result.Match = (result.OneTimeToken.Content == token)
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|