hmn/src/website/auth.go

893 lines
24 KiB
Go
Raw Normal View History

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"
2022-08-13 19:15:00 +00:00
"git.handmade.network/hmn/hmn/src/config"
2021-04-25 19:33:22 +00:00
"git.handmade.network/hmn/hmn/src/db"
Add Discord login (#106) This leverages our existing Discord OAuth implementation. Any users with a linked Discord account will be able to log in immediately. When logging in, we request the `email` scope in addition to `identity`, so existing users will be prompted one time to accept the new permissions. On subsequent logins, Discord will skip the prompt. When linking your Discord account to an existing HMN account, we continue to only request the `identity` scope, so we do not receive the user's Discord email. Both login and linking go through the same Discord OAuth callback. All flows through the callback try to achieve the same end goal: a logged-in HMN user with a linked Discord account. Linking works the same as it ever has. Login, however, is different because we do not have a session ID to use as the OAuth state. To account for this, I have added a `pending_login` table that stores a secure unique ID and the eventual destination URL. These pending logins expire after 10 minutes. When we receive the OAuth callback, we look up the pending login by the OAuth `state` and immediately delete it. The destination URL will be used to redirect the user to the right place. If we have a `discord_user` entry for the OAuth'd Discord user, we immediately log the user into the associated HMN account. This is the typical login case. If we do not have a `discord_user`, but there is exactly one HMN user with the same email address as the Discord user, we will link the two accounts and log into the HMN account. (It is possible for multiple HMN accounts to have the same email, because we don't have a uniqueness constraint there. We fail the login in this case rather than link to the wrong account.) Finally, if no associated HMN user exists, a new one will be created. It will use the Discord user's username, email, and avatar. This user will have no password, but they can set or reset a password through the usual flows. Co-authored-by: Ben Visness <bvisness@gmail.com> Reviewed-on: https://git.handmade.network/hmn/hmn/pulls/106
2023-05-06 19:38:50 +00:00
"git.handmade.network/hmn/hmn/src/discord"
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
Add Discord login (#106) This leverages our existing Discord OAuth implementation. Any users with a linked Discord account will be able to log in immediately. When logging in, we request the `email` scope in addition to `identity`, so existing users will be prompted one time to accept the new permissions. On subsequent logins, Discord will skip the prompt. When linking your Discord account to an existing HMN account, we continue to only request the `identity` scope, so we do not receive the user's Discord email. Both login and linking go through the same Discord OAuth callback. All flows through the callback try to achieve the same end goal: a logged-in HMN user with a linked Discord account. Linking works the same as it ever has. Login, however, is different because we do not have a session ID to use as the OAuth state. To account for this, I have added a `pending_login` table that stores a secure unique ID and the eventual destination URL. These pending logins expire after 10 minutes. When we receive the OAuth callback, we look up the pending login by the OAuth `state` and immediately delete it. The destination URL will be used to redirect the user to the right place. If we have a `discord_user` entry for the OAuth'd Discord user, we immediately log the user into the associated HMN account. This is the typical login case. If we do not have a `discord_user`, but there is exactly one HMN user with the same email address as the Discord user, we will link the two accounts and log into the HMN account. (It is possible for multiple HMN accounts to have the same email, because we don't have a uniqueness constraint there. We fail the login in this case rather than link to the wrong account.) Finally, if no associated HMN user exists, a new one will be created. It will use the Discord user's username, email, and avatar. This user will have no password, but they can set or reset a password through the usual flows. Co-authored-by: Ben Visness <bvisness@gmail.com> Reviewed-on: https://git.handmade.network/hmn/hmn/pulls/106
2023-05-06 19:38:50 +00:00
RedirectUrl string
RegisterUrl string
ForgotPasswordUrl string
LoginWithDiscordUrl string
2021-08-08 20:05:52 +00:00
}
func LoginPage(c *RequestContext) ResponseData {
if c.CurrentUser != nil {
return c.RejectRequest("You are already logged in.")
2021-08-08 20:05:52 +00:00
}
redirect := c.Req.URL.Query().Get("redirect")
2021-08-08 20:05:52 +00:00
var res ResponseData
res.MustWriteTemplate("auth_login.html", LoginPageData{
Add Discord login (#106) This leverages our existing Discord OAuth implementation. Any users with a linked Discord account will be able to log in immediately. When logging in, we request the `email` scope in addition to `identity`, so existing users will be prompted one time to accept the new permissions. On subsequent logins, Discord will skip the prompt. When linking your Discord account to an existing HMN account, we continue to only request the `identity` scope, so we do not receive the user's Discord email. Both login and linking go through the same Discord OAuth callback. All flows through the callback try to achieve the same end goal: a logged-in HMN user with a linked Discord account. Linking works the same as it ever has. Login, however, is different because we do not have a session ID to use as the OAuth state. To account for this, I have added a `pending_login` table that stores a secure unique ID and the eventual destination URL. These pending logins expire after 10 minutes. When we receive the OAuth callback, we look up the pending login by the OAuth `state` and immediately delete it. The destination URL will be used to redirect the user to the right place. If we have a `discord_user` entry for the OAuth'd Discord user, we immediately log the user into the associated HMN account. This is the typical login case. If we do not have a `discord_user`, but there is exactly one HMN user with the same email address as the Discord user, we will link the two accounts and log into the HMN account. (It is possible for multiple HMN accounts to have the same email, because we don't have a uniqueness constraint there. We fail the login in this case rather than link to the wrong account.) Finally, if no associated HMN user exists, a new one will be created. It will use the Discord user's username, email, and avatar. This user will have no password, but they can set or reset a password through the usual flows. Co-authored-by: Ben Visness <bvisness@gmail.com> Reviewed-on: https://git.handmade.network/hmn/hmn/pulls/106
2023-05-06 19:38:50 +00:00
BaseData: getBaseData(c, "Log in", nil),
RedirectUrl: redirect,
RegisterUrl: hmnurl.BuildRegister(redirect),
ForgotPasswordUrl: hmnurl.BuildRequestPasswordReset(),
LoginWithDiscordUrl: hmnurl.BuildLoginWithDiscord(redirect),
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 {
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")
2022-08-13 19:15:00 +00:00
destination := hmnurl.BuildHomepage()
if redirect != "" && urlIsLocal(redirect) {
destination = redirect
2021-08-17 05:18:04 +00:00
}
if c.CurrentUser != nil {
2022-08-13 19:15:00 +00:00
res := c.Redirect(destination, http.StatusSeeOther)
res.AddFutureNotice("warn", fmt.Sprintf("You are already logged in as %s.", c.CurrentUser.Username))
return res
}
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
2022-08-13 19:15:00 +00:00
baseData := getBaseData(c, "Log in", nil)
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
}
user, err := db.QueryOne[models.User](c, c.Conn,
2021-12-29 14:38:23 +00:00
`
SELECT $columns
FROM hmn_user
2021-12-29 14:38:23 +00:00
WHERE LOWER(username) = LOWER($1)
`,
username,
)
2021-04-25 19:33:22 +00:00
if err != nil {
if errors.Is(err, db.NotFound) {
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
}
}
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 c.RejectRequest("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-08-08 20:05:52 +00:00
}
2021-04-25 19:33:22 +00:00
2022-08-13 19:15:00 +00:00
res := c.Redirect(destination, http.StatusSeeOther)
2021-08-08 20:05:52 +00:00
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
}
Add Discord login (#106) This leverages our existing Discord OAuth implementation. Any users with a linked Discord account will be able to log in immediately. When logging in, we request the `email` scope in addition to `identity`, so existing users will be prompted one time to accept the new permissions. On subsequent logins, Discord will skip the prompt. When linking your Discord account to an existing HMN account, we continue to only request the `identity` scope, so we do not receive the user's Discord email. Both login and linking go through the same Discord OAuth callback. All flows through the callback try to achieve the same end goal: a logged-in HMN user with a linked Discord account. Linking works the same as it ever has. Login, however, is different because we do not have a session ID to use as the OAuth state. To account for this, I have added a `pending_login` table that stores a secure unique ID and the eventual destination URL. These pending logins expire after 10 minutes. When we receive the OAuth callback, we look up the pending login by the OAuth `state` and immediately delete it. The destination URL will be used to redirect the user to the right place. If we have a `discord_user` entry for the OAuth'd Discord user, we immediately log the user into the associated HMN account. This is the typical login case. If we do not have a `discord_user`, but there is exactly one HMN user with the same email address as the Discord user, we will link the two accounts and log into the HMN account. (It is possible for multiple HMN accounts to have the same email, because we don't have a uniqueness constraint there. We fail the login in this case rather than link to the wrong account.) Finally, if no associated HMN user exists, a new one will be created. It will use the Discord user's username, email, and avatar. This user will have no password, but they can set or reset a password through the usual flows. Co-authored-by: Ben Visness <bvisness@gmail.com> Reviewed-on: https://git.handmade.network/hmn/hmn/pulls/106
2023-05-06 19:38:50 +00:00
func LoginWithDiscord(c *RequestContext) ResponseData {
destinationUrl := c.URL().Query().Get("redirect")
if c.CurrentUser != nil {
return c.Redirect(destinationUrl, http.StatusSeeOther)
}
pendingLogin, err := db.QueryOne[models.PendingLogin](c, c.Conn,
`
INSERT INTO pending_login (id, expires_at, destination_url)
VALUES ($1, $2, $3)
RETURNING $columns
`,
auth.MakeSessionId(), time.Now().Add(time.Minute*10), destinationUrl,
)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to save pending login"))
}
discordAuthUrl := discord.GetAuthorizeUrl(pendingLogin.ID, true)
return c.Redirect(discordAuthUrl, http.StatusSeeOther)
}
2021-04-25 19:33:22 +00:00
func Logout(c *RequestContext) ResponseData {
2022-08-13 19:15:00 +00:00
redirect := c.Req.URL.Query().Get("redirect")
destination := hmnurl.BuildHomepage()
if redirect != "" && urlIsLocal(redirect) {
destination = redirect
}
2022-08-13 19:15:00 +00:00
res := c.Redirect(destination, 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)
}
2022-08-13 19:15:00 +00:00
2021-08-08 20:05:52 +00:00
// TODO(asaf): Do something to prevent bot registration
2022-08-13 19:15:00 +00:00
type RegisterPageData struct {
templates.BaseData
DestinationURL string
}
tmpl := RegisterPageData{
BaseData: getBaseData(c, "Register", nil),
DestinationURL: c.Req.URL.Query().Get("destination"),
}
2021-08-08 20:05:52 +00:00
var res ResponseData
2022-08-13 19:15:00 +00:00
res.MustWriteTemplate("auth_register.html", tmpl, c.Perf)
2021-08-08 20:05:52 +00:00
return res
}
func RegisterNewUserSubmit(c *RequestContext) ResponseData {
if c.CurrentUser != nil {
return c.RejectRequest("Can't register new user. You are already logged in")
2021-08-08 20:05:52 +00:00
}
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")
2022-08-13 19:15:00 +00:00
destination := strings.TrimSpace(c.Req.Form.Get("destination"))
2021-08-08 20:05:52 +00:00
if !UsernameRegex.Match([]byte(username)) {
return c.RejectRequest("Invalid username")
2021-08-08 20:05:52 +00:00
}
if !email.IsEmail(emailAddress) {
return c.RejectRequest("Invalid email address")
2021-08-08 20:05:52 +00:00
}
if len(password) < 8 {
return c.RejectRequest("Password too short")
2021-08-08 20:05:52 +00:00
}
2021-08-17 05:18:04 +00:00
c.Perf.StartBlock("SQL", "Check blacklist")
if emailIsBlacklisted(emailAddress) {
2021-08-17 05:18:04 +00:00
// 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.QueryOneScalar[int](c, c.Conn,
2021-08-08 20:05:52 +00:00
`
SELECT id
2022-05-07 13:11:05 +00:00
FROM hmn_user
2021-08-08 20:05:52 +00:00
WHERE LOWER(username) = LOWER($1)
`,
username,
)
if err != nil {
if errors.Is(err, db.NotFound) {
2021-08-08 20:05:52 +00:00
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 c.RejectRequest(fmt.Sprintf("Username (%s) already exists.", username))
2021-08-08 20:05:52 +00:00
}
existingUser, err := db.QueryOne[models.User](c, c.Conn,
2021-08-08 20:05:52 +00:00
`
SELECT $columns
2022-05-07 13:11:05 +00:00
FROM hmn_user
2021-08-08 20:05:52 +00:00
WHERE LOWER(email) = LOWER($1)
`,
emailAddress,
)
if errors.Is(err, db.NotFound) {
// this is fine
} else if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user"))
2021-08-08 20:05:52 +00:00
}
c.Perf.EndBlock()
if existingUser != nil {
// Render the page as if it was a successful new registration, but
// instead send an email to the duplicate email address containing
// their actual username. Spammers won't be able to harvest emails, but
// normal users will be able to find and access their old accounts.
err := email.SendExistingAccountEmail(
existingUser.Email,
existingUser.BestName(),
existingUser.Username,
destination,
c.Perf,
)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to send existing account email"))
}
2021-08-08 20:05:52 +00:00
return c.Redirect(hmnurl.BuildRegistrationSuccess(), http.StatusSeeOther)
}
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)
2021-08-08 20:05:52 +00:00
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)
2021-08-08 20:05:52 +00:00
now := time.Now()
var newUserId int
err = tx.QueryRow(c,
2021-08-08 20:05:52 +00:00
`
2022-05-07 13:11:05 +00:00
INSERT INTO hmn_user (username, email, password, date_joined, name, registration_ip)
2021-08-08 20:05:52 +00:00
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,
2021-08-08 20:05:52 +00:00
`
2022-05-07 13:11:05 +00:00
INSERT INTO one_time_token (token_type, created, expires, token_content, owner_id)
2021-08-08 20:05:52 +00:00
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
}
2022-08-13 19:15:00 +00:00
err = email.SendRegistrationEmail(
emailAddress,
mailName,
username,
ott,
destination,
c.Perf,
)
2021-08-08 20:05:52 +00:00
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)
2021-08-08 20:05:52 +00:00
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{
2022-08-13 19:15:00 +00:00
BaseData: getBaseData(c, "Register", nil),
2021-08-08 20:05:52 +00:00
ContactUsUrl: hmnurl.BuildContactPage(),
}, c.Perf)
return res
}
type EmailValidationData struct {
templates.BaseData
2022-08-13 19:15:00 +00:00
Token string
Username string
DestinationURL string
2021-08-08 20:05:52 +00:00
}
func EmailConfirmation(c *RequestContext) ResponseData {
if c.CurrentUser != nil {
return c.Redirect(hmnurl.BuildHomepage(), http.StatusSeeOther)
}
username, hasUsername := c.PathParams["username"]
if !hasUsername {
return c.RejectRequest("Bad validation url")
2021-08-08 20:05:52 +00:00
}
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 c.RejectRequest("Bad validation url")
2021-08-08 20:05:52 +00:00
}
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{
2022-08-13 19:15:00 +00:00
BaseData: getBaseData(c, "Register", nil),
Token: token,
Username: username,
DestinationURL: c.Req.URL.Query().Get("destination"),
2021-08-08 20:05:52 +00:00
}, 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")
2022-08-13 19:15:00 +00:00
destination := c.Req.Form.Get("destination")
2021-08-08 20:05:52 +00:00
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
2022-08-13 19:15:00 +00:00
baseData := getBaseData(c, "Register", nil)
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{
BaseData: baseData,
Token: token,
Username: username,
DestinationURL: destination,
2021-08-08 20:05:52 +00:00
}, c.Perf)
return res
}
c.Perf.StartBlock("SQL", "Updating user status and deleting token")
tx, err := c.Conn.Begin(c)
2021-08-08 20:05:52 +00:00
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)
2021-08-08 20:05:52 +00:00
_, err = tx.Exec(c,
2021-08-08 20:05:52 +00:00
`
2022-05-07 13:11:05 +00:00
UPDATE hmn_user
2021-08-08 20:05:52 +00:00
SET status = $1
WHERE id = $2
`,
2021-09-24 00:12:46 +00:00
models.UserStatusConfirmed,
2021-08-08 20:05:52 +00:00
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,
2021-08-08 20:05:52 +00:00
`
2022-05-07 13:11:05 +00:00
DELETE FROM one_time_token WHERE id = $1
2021-08-08 20:05:52 +00:00
`,
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)
2021-08-08 20:05:52 +00:00
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()
2022-08-13 19:15:00 +00:00
redirect := hmnurl.BuildHomepage()
if destination != "" && urlIsLocal(destination) {
redirect = destination
}
res := c.Redirect(redirect, http.StatusSeeOther)
2021-08-17 05:18:04 +00:00
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 c.RejectRequest("You haven't validated your email in time and your user was deleted. You may try registering again with the same username.")
2021-08-17 05:18:04 +00:00
}
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 c.RejectRequest("Bad token. If you are having problems registering or logging in, please contact the staff.")
2021-08-17 05:18:04 +00:00
}
2021-08-08 20:05:52 +00:00
// NOTE(asaf): PasswordReset refers specifically to "forgot your password" flow over email,
Add Discord login (#106) This leverages our existing Discord OAuth implementation. Any users with a linked Discord account will be able to log in immediately. When logging in, we request the `email` scope in addition to `identity`, so existing users will be prompted one time to accept the new permissions. On subsequent logins, Discord will skip the prompt. When linking your Discord account to an existing HMN account, we continue to only request the `identity` scope, so we do not receive the user's Discord email. Both login and linking go through the same Discord OAuth callback. All flows through the callback try to achieve the same end goal: a logged-in HMN user with a linked Discord account. Linking works the same as it ever has. Login, however, is different because we do not have a session ID to use as the OAuth state. To account for this, I have added a `pending_login` table that stores a secure unique ID and the eventual destination URL. These pending logins expire after 10 minutes. When we receive the OAuth callback, we look up the pending login by the OAuth `state` and immediately delete it. The destination URL will be used to redirect the user to the right place. If we have a `discord_user` entry for the OAuth'd Discord user, we immediately log the user into the associated HMN account. This is the typical login case. If we do not have a `discord_user`, but there is exactly one HMN user with the same email address as the Discord user, we will link the two accounts and log into the HMN account. (It is possible for multiple HMN accounts to have the same email, because we don't have a uniqueness constraint there. We fail the login in this case rather than link to the wrong account.) Finally, if no associated HMN user exists, a new one will be created. It will use the Discord user's username, email, and avatar. This user will have no password, but they can set or reset a password through the usual flows. Co-authored-by: Ben Visness <bvisness@gmail.com> Reviewed-on: https://git.handmade.network/hmn/hmn/pulls/106
2023-05-06 19:38:50 +00:00
//
// not to changing your password through the user settings page.
2021-08-08 20:05:52 +00:00
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
2022-08-13 19:15:00 +00:00
res.MustWriteTemplate("auth_password_reset.html", getBaseData(c, "Password Reset", nil), 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 c.RejectRequest("You must provide a username and an email address.")
2021-08-17 05:18:04 +00:00
}
c.Perf.StartBlock("SQL", "Fetching user")
2021-12-29 14:38:23 +00:00
type userQuery struct {
2022-05-07 13:11:05 +00:00
User models.User `db:"hmn_user"`
2021-12-29 14:38:23 +00:00
}
user, err := db.QueryOne[models.User](c, c.Conn,
2021-08-17 05:18:04 +00:00
`
SELECT $columns
FROM hmn_user
2021-08-17 05:18:04 +00:00
WHERE
LOWER(username) = LOWER($1)
AND LOWER(email) = LOWER($2)
`,
username,
emailAddress,
)
c.Perf.EndBlock()
if err != nil {
if !errors.Is(err, db.NotFound) {
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 user != nil && user.Status != models.UserStatusBanned {
2021-08-17 05:18:04 +00:00
c.Perf.StartBlock("SQL", "Fetching existing token")
resetToken, err := db.QueryOne[models.OneTimeToken](c, c.Conn,
2021-08-17 05:18:04 +00:00
`
SELECT $columns
2022-05-07 13:11:05 +00:00
FROM one_time_token
2021-08-17 05:18:04 +00:00
WHERE
token_type = $1
AND owner_id = $2
`,
models.TokenTypePasswordReset,
user.ID,
)
c.Perf.EndBlock()
if err != nil {
if !errors.Is(err, db.NotFound) {
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
}
}
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,
2021-08-17 05:18:04 +00:00
`
2022-05-07 13:11:05 +00:00
DELETE FROM one_time_token
2021-08-17 05:18:04 +00:00
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")
newToken, err := db.QueryOne[models.OneTimeToken](c, c.Conn,
2021-08-17 05:18:04 +00:00
`
2022-05-07 13:11:05 +00:00
INSERT INTO one_time_token (token_type, created, expires, token_content, owner_id)
2021-08-17 05:18:04 +00:00
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 = newToken
2021-08-17 05:18:04 +00:00
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{
2022-08-13 19:15:00 +00:00
BaseData: getBaseData(c, "Password Reset", nil),
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 c.RejectRequest("Bad validation url.")
2021-08-17 05:18:04 +00:00
}
validationResult := validateUsernameAndToken(c, username, token, models.TokenTypePasswordReset)
if !validationResult.Match {
return c.RejectRequest("Bad validation url.")
2021-08-17 05:18:04 +00:00
}
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{
2022-08-13 19:15:00 +00:00
BaseData: getBaseData(c, "Password Reset", nil),
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")
validationResult := validateUsernameAndToken(c, username, token, models.TokenTypePasswordReset)
if !validationResult.Match {
return c.RejectRequest("Bad validation url.")
2021-08-17 05:18:04 +00:00
}
if c.CurrentUser != nil && c.CurrentUser.ID != validationResult.User.ID {
return c.RejectRequest(fmt.Sprintf("Can't change password for %s. You are logged in as %s.", username, c.CurrentUser.Username))
2021-08-17 05:18:04 +00:00
}
if len(password) < 8 {
return c.RejectRequest("Password too short")
2021-08-17 05:18:04 +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)
2021-08-17 05:18:04 +00:00
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)
2021-08-17 05:18:04 +00:00
tag, err := tx.Exec(c,
2021-08-17 05:18:04 +00:00
`
2022-05-07 13:11:05 +00:00
UPDATE hmn_user
2021-08-17 05:18:04 +00:00
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,
2021-08-17 05:18:04 +00:00
`
2022-05-07 13:11:05 +00:00
UPDATE hmn_user
2021-08-17 05:18:04 +00:00
SET status = $1
WHERE id = $2
`,
models.UserStatusConfirmed,
2021-08-17 05:18:04 +00:00
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,
2021-08-17 05:18:04 +00:00
`
2022-05-07 13:11:05 +00:00
DELETE FROM one_time_token
2021-08-17 05:18:04 +00:00
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)
2021-08-17 05:18:04 +00:00
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) {
if user.Status == models.UserStatusBanned {
return false, nil
}
2021-08-08 20:05:52 +00:00
c.Perf.StartBlock("AUTH", "Checking password")
defer c.Perf.EndBlock()
hashed, err := auth.ParsePasswordString(user.Password)
if err != nil {
Add Discord login (#106) This leverages our existing Discord OAuth implementation. Any users with a linked Discord account will be able to log in immediately. When logging in, we request the `email` scope in addition to `identity`, so existing users will be prompted one time to accept the new permissions. On subsequent logins, Discord will skip the prompt. When linking your Discord account to an existing HMN account, we continue to only request the `identity` scope, so we do not receive the user's Discord email. Both login and linking go through the same Discord OAuth callback. All flows through the callback try to achieve the same end goal: a logged-in HMN user with a linked Discord account. Linking works the same as it ever has. Login, however, is different because we do not have a session ID to use as the OAuth state. To account for this, I have added a `pending_login` table that stores a secure unique ID and the eventual destination URL. These pending logins expire after 10 minutes. When we receive the OAuth callback, we look up the pending login by the OAuth `state` and immediately delete it. The destination URL will be used to redirect the user to the right place. If we have a `discord_user` entry for the OAuth'd Discord user, we immediately log the user into the associated HMN account. This is the typical login case. If we do not have a `discord_user`, but there is exactly one HMN user with the same email address as the Discord user, we will link the two accounts and log into the HMN account. (It is possible for multiple HMN accounts to have the same email, because we don't have a uniqueness constraint there. We fail the login in this case rather than link to the wrong account.) Finally, if no associated HMN user exists, a new one will be created. It will use the Discord user's username, email, and avatar. This user will have no password, but they can set or reset a password through the usual flows. Co-authored-by: Ben Visness <bvisness@gmail.com> Reviewed-on: https://git.handmade.network/hmn/hmn/pulls/106
2023-05-06 19:38:50 +00:00
if user.Password == "" {
return false, nil
} else {
return false, oops.New(err, "failed to parse password string")
}
2021-08-08 20:05:52 +00:00
}
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, 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)
2021-08-08 20:05:52 +00:00
if err != nil {
return oops.New(err, "failed to start db transaction")
}
defer tx.Rollback(c)
2021-08-08 20:05:52 +00:00
now := time.Now()
_, err = tx.Exec(c,
2021-08-08 20:05:52 +00:00
`
2022-05-07 13:11:05 +00:00
UPDATE hmn_user
2021-08-08 20:05:52 +00:00
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, c.Conn, user.Username)
2021-08-08 20:05:52 +00:00
if err != nil {
return oops.New(err, "failed to create session")
}
err = tx.Commit(c)
2021-08-08 20:05:52 +00:00
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, c.Conn, sessionCookie.Value)
2021-08-17 05:18:04 +00:00
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 {
2022-05-07 13:11:05 +00:00
User models.User `db:"hmn_user"`
2021-08-08 20:05:52 +00:00
OneTimeToken *models.OneTimeToken `db:"onetimetoken"`
}
data, err := db.QueryOne[userAndTokenQuery](c, c.Conn,
2021-08-08 20:05:52 +00:00
`
SELECT $columns
2022-05-07 13:11:05 +00:00
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
2021-08-17 05:18:04 +00:00
WHERE
2022-05-07 13:11:05 +00:00
LOWER(hmn_user.username) = LOWER($1)
2021-08-17 05:18:04 +00:00
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 {
if !errors.Is(err, db.NotFound) {
2021-08-17 05:18:04 +00:00
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 data != nil {
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
}
2022-08-13 19:15:00 +00:00
func urlIsLocal(url string) bool {
return strings.HasPrefix(url, config.Config.BaseUrl)
}
func emailIsBlacklisted(email string) bool {
if strings.Count(email, ".") > 5 {
return true
}
// TODO(asaf): Actually check email against blacklist
return false
}