2021-04-25 19:33:22 +00:00
package website
import (
"errors"
2021-08-08 20:05:52 +00:00
"fmt"
2021-04-25 19:33:22 +00:00
"net/http"
2021-08-08 20:05:52 +00:00
"regexp"
"strings"
"time"
2021-04-25 19:33:22 +00:00
"git.handmade.network/hmn/hmn/src/auth"
"git.handmade.network/hmn/hmn/src/db"
2021-08-08 20:05:52 +00:00
"git.handmade.network/hmn/hmn/src/email"
"git.handmade.network/hmn/hmn/src/hmnurl"
2021-04-25 19:33:22 +00:00
"git.handmade.network/hmn/hmn/src/logging"
"git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/oops"
2021-08-08 20:05:52 +00:00
"git.handmade.network/hmn/hmn/src/templates"
2021-04-25 19:33:22 +00:00
)
2021-08-08 20:05:52 +00:00
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 {
2021-09-01 18:25:09 +00:00
BaseData : getBaseDataAutocrumb ( c , "Log in" ) ,
2021-08-08 20:05:52 +00:00
RedirectUrl : c . Req . URL . Query ( ) . Get ( "redirect" ) ,
2021-08-17 05:18:04 +00:00
ForgotPasswordUrl : hmnurl . BuildRequestPasswordReset ( ) ,
2021-08-08 20:05:52 +00:00
} , c . Perf )
return res
}
2021-04-25 19:33:22 +00:00
func Login ( c * RequestContext ) ResponseData {
2021-08-08 20:05:52 +00:00
if c . CurrentUser != nil {
return RejectRequest ( c , "You are already logged in." )
}
2021-04-25 19:33:22 +00:00
form , err := c . GetFormValues ( )
if err != nil {
2021-08-28 12:21:03 +00:00
return c . ErrorResponse ( http . StatusBadRequest , NewSafeError ( err , "request must contain form data" ) )
2021-04-25 19:33:22 +00:00
}
2021-08-17 05:18:04 +00:00
redirect := form . Get ( "redirect" )
if redirect == "" {
redirect = "/"
}
2021-04-25 19:33:22 +00:00
username := form . Get ( "username" )
password := form . Get ( "password" )
if username == "" || password == "" {
2021-08-17 05:18:04 +00:00
return c . Redirect ( hmnurl . BuildLoginPage ( redirect ) , http . StatusSeeOther )
2021-04-25 19:33:22 +00:00
}
2021-08-17 05:18:04 +00:00
showLoginWithFailure := func ( c * RequestContext , redirect string ) ResponseData {
var res ResponseData
2021-09-01 18:25:09 +00:00
baseData := getBaseDataAutocrumb ( c , "Log in" )
2021-08-17 05:18:04 +00:00
baseData . AddImmediateNotice ( "failure" , "Incorrect username or password" )
res . MustWriteTemplate ( "auth_login.html" , LoginPageData {
BaseData : baseData ,
RedirectUrl : redirect ,
ForgotPasswordUrl : hmnurl . BuildRequestPasswordReset ( ) ,
} , c . Perf )
return res
2021-04-25 19:33:22 +00:00
}
2021-04-27 03:55:17 +00:00
userRow , err := db . QueryOne ( c . Context ( ) , c . Conn , models . User { } , "SELECT $columns FROM auth_user WHERE LOWER(username) = LOWER($1)" , username )
2021-04-25 19:33:22 +00:00
if err != nil {
if errors . Is ( err , db . ErrNoMatchingRows ) {
2021-08-17 05:18:04 +00:00
return showLoginWithFailure ( c , redirect )
2021-04-25 19:33:22 +00:00
} else {
2021-08-28 12:21:03 +00:00
return c . ErrorResponse ( http . StatusInternalServerError , oops . New ( err , "failed to look up user by username" ) )
2021-04-25 19:33:22 +00:00
}
}
user := userRow . ( * models . User )
2021-08-08 20:05:52 +00:00
success , err := tryLogin ( c , user , password )
2021-04-25 19:33:22 +00:00
if err != nil {
2021-08-28 12:21:03 +00:00
return c . ErrorResponse ( http . StatusInternalServerError , err )
2021-04-25 19:33:22 +00:00
}
2021-08-08 20:05:52 +00:00
if ! success {
2021-08-17 05:18:04 +00:00
return showLoginWithFailure ( c , redirect )
2021-08-08 20:05:52 +00:00
}
2021-04-25 19:33:22 +00:00
2021-08-08 20:05:52 +00:00
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." )
}
2021-04-25 19:33:22 +00:00
2021-08-08 20:05:52 +00:00
res := c . Redirect ( redirect , http . StatusSeeOther )
err = loginUser ( c , user , & res )
if err != nil {
2021-08-28 12:21:03 +00:00
return c . ErrorResponse ( http . StatusInternalServerError , err )
2021-04-25 19:33:22 +00:00
}
2021-08-08 20:05:52 +00:00
return res
2021-04-25 19:33:22 +00:00
}
func Logout ( c * RequestContext ) ResponseData {
2021-06-12 00:48:03 +00:00
redir := c . Req . URL . Query ( ) . Get ( "redirect" )
if redir == "" {
redir = "/"
}
res := c . Redirect ( redir , http . StatusSeeOther )
2021-08-17 05:18:04 +00:00
logoutUser ( c , & res )
2021-04-25 19:33:22 +00:00
return res
}
2021-08-08 20:05:52 +00:00
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
2021-09-01 18:25:09 +00:00
res . MustWriteTemplate ( "auth_register.html" , getBaseDataAutocrumb ( c , "Register" ) , c . Perf )
2021-08-08 20:05:52 +00:00
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" )
}
2021-08-17 05:18:04 +00:00
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 ( )
2021-08-08 20:05:52 +00:00
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 . ErrNoMatchingRows ) {
userAlreadyExists = false
} else {
2021-08-28 12:21:03 +00:00
return c . ErrorResponse ( http . StatusInternalServerError , oops . New ( err , "failed to fetch user" ) )
2021-08-08 20:05:52 +00:00
}
}
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 . ErrNoMatchingRows ) {
emailAlreadyExists = false
} else {
2021-08-28 12:21:03 +00:00
return c . ErrorResponse ( http . StatusInternalServerError , oops . New ( err , "failed to fetch user" ) )
2021-08-08 20:05:52 +00:00
}
}
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 )
}
2021-08-27 17:58:52 +00:00
hashed := auth . HashPassword ( password )
2021-08-08 20:05:52 +00:00
c . Perf . StartBlock ( "SQL" , "Create user and one time token" )
tx , err := c . Conn . Begin ( c . Context ( ) )
if err != nil {
2021-08-28 12:21:03 +00:00
return c . ErrorResponse ( http . StatusInternalServerError , oops . New ( err , "failed to start db transaction" ) )
2021-08-08 20:05:52 +00:00
}
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 {
2021-08-28 12:21:03 +00:00
return c . ErrorResponse ( http . StatusInternalServerError , oops . New ( err , "failed to store user" ) )
2021-08-08 20:05:52 +00:00
}
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 {
2021-08-28 12:21:03 +00:00
return c . ErrorResponse ( http . StatusInternalServerError , oops . New ( err , "failed to store one-time token" ) )
2021-08-08 20:05:52 +00:00
}
c . Perf . EndBlock ( )
mailName := displayName
if mailName == "" {
mailName = username
}
err = email . SendRegistrationEmail ( emailAddress , mailName , username , ott , c . Perf )
if err != nil {
2021-08-28 12:21:03 +00:00
return c . ErrorResponse ( http . StatusInternalServerError , oops . New ( err , "failed to send registration email" ) )
2021-08-08 20:05:52 +00:00
}
c . Perf . StartBlock ( "SQL" , "Commit user" )
err = tx . Commit ( c . Context ( ) )
if err != nil {
2021-08-28 12:21:03 +00:00
return c . ErrorResponse ( http . StatusInternalServerError , oops . New ( err , "failed to commit user to the db" ) )
2021-08-08 20:05:52 +00:00
}
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 {
2021-09-01 18:25:09 +00:00
BaseData : getBaseDataAutocrumb ( c , "Register" ) ,
2021-08-08 20:05:52 +00:00
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" )
}
2021-08-17 05:18:04 +00:00
validationResult := validateUsernameAndToken ( c , username , token , models . TokenTypeRegistration )
if ! validationResult . Match {
return makeResponseForBadRegistrationTokenValidationResult ( c , validationResult )
2021-08-08 20:05:52 +00:00
}
var res ResponseData
res . MustWriteTemplate ( "auth_email_validation.html" , EmailValidationData {
2021-09-01 18:25:09 +00:00
BaseData : getBaseDataAutocrumb ( c , "Register" ) ,
2021-08-08 20:05:52 +00:00
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" )
2021-08-17 05:18:04 +00:00
validationResult := validateUsernameAndToken ( c , username , token , models . TokenTypeRegistration )
if ! validationResult . Match {
return makeResponseForBadRegistrationTokenValidationResult ( c , validationResult )
2021-08-08 20:05:52 +00:00
}
success , err := tryLogin ( c , validationResult . User , password )
if err != nil {
2021-08-28 12:21:03 +00:00
return c . ErrorResponse ( http . StatusInternalServerError , err )
2021-08-08 20:05:52 +00:00
} else if ! success {
var res ResponseData
2021-09-01 18:25:09 +00:00
baseData := getBaseDataAutocrumb ( c , "Register" )
2021-08-08 20:05:52 +00:00
// NOTE(asaf): We can report that the password is incorrect, because an attacker wouldn't have a valid token to begin with.
2021-08-17 05:18:04 +00:00
baseData . AddImmediateNotice ( "failure" , "Incorrect password. Please try again." )
2021-08-08 20:05:52 +00:00
res . MustWriteTemplate ( "auth_email_validation.html" , EmailValidationData {
2021-09-01 18:25:09 +00:00
BaseData : baseData ,
2021-08-08 20:05:52 +00:00
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 {
2021-08-28 12:21:03 +00:00
return c . ErrorResponse ( http . StatusInternalServerError , oops . New ( err , "failed to start db transaction" ) )
2021-08-08 20:05:52 +00:00
}
defer tx . Rollback ( c . Context ( ) )
_ , err = tx . Exec ( c . Context ( ) ,
`
UPDATE auth_user
SET status = $ 1
WHERE id = $ 2
` ,
models . UserStatusActive ,
validationResult . User . ID ,
)
if err != nil {
2021-08-28 12:21:03 +00:00
return c . ErrorResponse ( http . StatusInternalServerError , oops . New ( err , "failed to update user status" ) )
2021-08-08 20:05:52 +00:00
}
_ , err = tx . Exec ( c . Context ( ) ,
`
DELETE FROM handmade_onetimetoken WHERE id = $ 1
` ,
validationResult . OneTimeToken . ID ,
)
if err != nil {
2021-08-28 12:21:03 +00:00
return c . ErrorResponse ( http . StatusInternalServerError , oops . New ( err , "failed to delete one time token" ) )
2021-08-08 20:05:52 +00:00
}
err = tx . Commit ( c . Context ( ) )
if err != nil {
2021-08-28 12:21:03 +00:00
return c . ErrorResponse ( http . StatusInternalServerError , oops . New ( err , "failed to commit transaction" ) )
2021-08-08 20:05:52 +00:00
}
c . Perf . EndBlock ( )
2021-08-17 05:18:04 +00:00
res := c . Redirect ( hmnurl . BuildHomepage ( ) , http . StatusSeeOther )
res . AddFutureNotice ( "success" , "You've completed your registration successfully!" )
2021-08-08 20:05:52 +00:00
err = loginUser ( c , validationResult . User , & res )
if err != nil {
2021-08-28 12:21:03 +00:00
return c . ErrorResponse ( http . StatusInternalServerError , err )
2021-08-08 20:05:52 +00:00
}
return res
}
2021-08-17 05:18:04 +00:00
// 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." )
}
2021-08-08 20:05:52 +00:00
// 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 {
2021-08-17 05:18:04 +00:00
if c . CurrentUser != nil {
return c . Redirect ( hmnurl . BuildHomepage ( ) , http . StatusSeeOther )
}
var res ResponseData
2021-09-01 18:25:09 +00:00
res . MustWriteTemplate ( "auth_password_reset.html" , getBaseDataAutocrumb ( c , "Password Reset" ) , c . Perf )
2021-08-17 05:18:04 +00:00
return res
2021-08-08 20:05:52 +00:00
}
func RequestPasswordResetSubmit ( c * RequestContext ) ResponseData {
2021-08-17 05:18:04 +00:00
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 . ErrNoMatchingRows ) {
2021-08-28 12:21:03 +00:00
return c . ErrorResponse ( http . StatusInternalServerError , oops . New ( err , "failed to look up user by username" ) )
2021-08-17 05:18:04 +00:00
}
}
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 . ErrNoMatchingRows ) {
2021-08-28 12:21:03 +00:00
return c . ErrorResponse ( http . StatusInternalServerError , oops . New ( err , "failed to fetch onetimetoken for user" ) )
2021-08-17 05:18:04 +00:00
}
}
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 {
2021-08-28 12:21:03 +00:00
return c . ErrorResponse ( http . StatusInternalServerError , oops . New ( err , "failed to delete onetimetoken" ) )
2021-08-17 05:18:04 +00:00
}
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 {
2021-08-28 12:21:03 +00:00
return c . ErrorResponse ( http . StatusInternalServerError , oops . New ( err , "failed to create onetimetoken" ) )
2021-08-17 05:18:04 +00:00
}
resetToken = tokenRow . ( * models . OneTimeToken )
err = email . SendPasswordReset ( user . Email , user . BestName ( ) , user . Username , resetToken . Content , resetToken . Expires , c . Perf )
if err != nil {
2021-08-28 12:21:03 +00:00
return c . ErrorResponse ( http . StatusInternalServerError , oops . New ( err , "failed to send email" ) )
2021-08-17 05:18:04 +00:00
}
}
}
return c . Redirect ( hmnurl . BuildPasswordResetSent ( ) , http . StatusSeeOther )
2021-08-08 20:05:52 +00:00
}
2021-08-17 05:18:04 +00:00
type PasswordResetSentData struct {
templates . BaseData
ContactUsUrl string
2021-08-08 20:05:52 +00:00
}
2021-08-17 05:18:04 +00:00
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 {
2021-09-01 18:25:09 +00:00
BaseData : getBaseDataAutocrumb ( c , "Password Reset" ) ,
2021-08-17 05:18:04 +00:00
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 {
2021-09-01 18:25:09 +00:00
BaseData : getBaseDataAutocrumb ( c , "Password Reset" ) ,
2021-08-17 05:18:04 +00:00
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" )
}
2021-08-27 17:58:52 +00:00
hashed := auth . HashPassword ( password )
2021-08-17 05:18:04 +00:00
c . Perf . StartBlock ( "SQL" , "Update user's password and delete reset token" )
tx , err := c . Conn . Begin ( c . Context ( ) )
if err != nil {
2021-08-28 12:21:03 +00:00
return c . ErrorResponse ( http . StatusInternalServerError , oops . New ( err , "failed to start db transaction" ) )
2021-08-17 05:18:04 +00:00
}
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 {
2021-08-28 12:21:03 +00:00
return c . ErrorResponse ( http . StatusInternalServerError , oops . New ( err , "failed to update user's password" ) )
2021-08-17 05:18:04 +00:00
}
if validationResult . User . Status == models . UserStatusInactive {
_ , err = tx . Exec ( c . Context ( ) ,
`
UPDATE auth_user
SET status = $ 1
WHERE id = $ 2
` ,
models . UserStatusActive ,
validationResult . User . ID ,
)
if err != nil {
2021-08-28 12:21:03 +00:00
return c . ErrorResponse ( http . StatusInternalServerError , oops . New ( err , "failed to update user's status" ) )
2021-08-17 05:18:04 +00:00
}
}
_ , err = tx . Exec ( c . Context ( ) ,
`
DELETE FROM handmade_onetimetoken
WHERE id = $ 1
` ,
validationResult . OneTimeToken . ID ,
)
if err != nil {
2021-08-28 12:21:03 +00:00
return c . ErrorResponse ( http . StatusInternalServerError , oops . New ( err , "failed to delete onetimetoken" ) )
2021-08-17 05:18:04 +00:00
}
err = tx . Commit ( c . Context ( ) )
if err != nil {
2021-08-28 12:21:03 +00:00
return c . ErrorResponse ( http . StatusInternalServerError , oops . New ( err , "failed to commit password reset to the db" ) )
2021-08-17 05:18:04 +00:00
}
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 {
2021-08-28 12:21:03 +00:00
return c . ErrorResponse ( http . StatusInternalServerError , err )
2021-08-17 05:18:04 +00:00
}
return res
2021-08-08 20:05:52 +00:00
}
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 ( ) {
2021-08-27 17:58:52 +00:00
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" )
2021-08-08 20:05:52 +00:00
}
// 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
}
2021-08-17 05:18:04 +00:00
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 )
}
2021-08-08 20:05:52 +00:00
type validateUserAndTokenResult struct {
2021-08-17 05:18:04 +00:00
User * models . User
OneTimeToken * models . OneTimeToken
Match bool
Error error
2021-08-08 20:05:52 +00:00
}
2021-08-17 05:18:04 +00:00
func validateUsernameAndToken ( c * RequestContext , username string , token string , tokenType models . OneTimeTokenType ) validateUserAndTokenResult {
2021-08-08 20:05:52 +00:00
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
2021-08-17 05:18:04 +00:00
WHERE
LOWER ( auth_user . username ) = LOWER ( $ 1 )
AND onetimetoken . token_type = $ 2
2021-08-08 20:05:52 +00:00
` ,
username ,
2021-08-17 05:18:04 +00:00
tokenType ,
2021-08-08 20:05:52 +00:00
)
var result validateUserAndTokenResult
if err != nil {
2021-08-17 05:18:04 +00:00
if ! errors . Is ( err , db . ErrNoMatchingRows ) {
result . Error = oops . New ( err , "failed to fetch user and token from db" )
return result
2021-08-08 20:05:52 +00:00
}
2021-08-17 05:18:04 +00:00
}
if row != nil {
2021-08-08 20:05:52 +00:00
data := row . ( * userAndTokenQuery )
2021-08-17 05:18:04 +00:00
result . User = & data . User
result . OneTimeToken = data . OneTimeToken
if result . OneTimeToken != nil {
result . Match = ( result . OneTimeToken . Content == token )
2021-08-08 20:05:52 +00:00
}
}
return result
}