forked from mirrors/pronouns.cc
225 lines
5.9 KiB
Go
225 lines
5.9 KiB
Go
package auth
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"codeberg.org/pronounscc/pronouns.cc/backend/common"
|
|
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
|
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
|
"emperror.dev/errors"
|
|
"github.com/go-chi/render"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/mediocregopher/radix/v4"
|
|
)
|
|
|
|
type putEmailRequest struct {
|
|
Email string `json:"email"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
func (s *Server) putEmail(w http.ResponseWriter, r *http.Request) error {
|
|
ctx := r.Context()
|
|
claims, _ := server.ClaimsFromContext(ctx)
|
|
if claims.APIToken {
|
|
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
|
|
}
|
|
|
|
var req putEmailRequest
|
|
err := render.Decode(r, &req)
|
|
if err != nil {
|
|
return server.APIError{Code: server.ErrBadRequest}
|
|
}
|
|
|
|
u, err := s.DB.User(ctx, claims.UserID)
|
|
if err != nil {
|
|
// this should never fail
|
|
log.Errorf("getting user: %v", err)
|
|
return errors.Wrap(err, "getting user")
|
|
}
|
|
|
|
tx, err := s.DB.Begin(ctx)
|
|
if err != nil {
|
|
return errors.Wrap(err, "beginning transaction")
|
|
}
|
|
defer func() {
|
|
_ = tx.Rollback(ctx)
|
|
}()
|
|
|
|
emails, err := s.DB.UserEmailsTx(ctx, tx, u.SnowflakeID)
|
|
if err != nil {
|
|
log.Errorf("getting user emails: %v", err)
|
|
return errors.Wrap(err, "getting user emails")
|
|
}
|
|
|
|
if len(emails) > 0 {
|
|
if emails[0].EmailAddress == req.Email {
|
|
return server.APIError{Code: server.ErrBadRequest, Details: "New email address cannot be the same as the current one"}
|
|
}
|
|
|
|
return s.putEmailExisting(w, r, tx, u, req)
|
|
}
|
|
|
|
ticket := common.RandBase64(48)
|
|
err = s.DB.Redis.Do(ctx, radix.Cmd(nil, "SET",
|
|
emailChangeTicketKey(ticket), emailChangeTicketValue(req.Email, u.SnowflakeID), "EX", "3600"))
|
|
if err != nil {
|
|
return errors.Wrap(err, "setting email change key")
|
|
}
|
|
|
|
// if the email address already exists, pretend we sent an email and return
|
|
exists, err := s.DB.EmailExistsTx(ctx, tx, req.Email)
|
|
if err != nil {
|
|
return errors.Wrap(err, "checking if email exists")
|
|
}
|
|
if exists {
|
|
render.NoContent(w, r)
|
|
return nil
|
|
}
|
|
|
|
// set the user's password, this won't do anything unless the email address is actually confirmed
|
|
err = s.DB.SetPassword(ctx, tx, u.SnowflakeID, req.Password)
|
|
if err != nil {
|
|
return errors.Wrap(err, "setting user password")
|
|
}
|
|
|
|
err = tx.Commit(ctx)
|
|
if err != nil {
|
|
return errors.Wrap(err, "committing transaction")
|
|
}
|
|
|
|
// send the email
|
|
go s.SendEmail(req.Email, "Confirm your email address", "change", map[string]any{
|
|
"Ticket": ticket,
|
|
"Username": u.Username,
|
|
})
|
|
|
|
render.NoContent(w, r)
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) putEmailExisting(
|
|
w http.ResponseWriter, r *http.Request, tx pgx.Tx, u db.User, req putEmailRequest,
|
|
) error {
|
|
ctx := r.Context()
|
|
|
|
if !u.VerifyPassword(req.Password) {
|
|
return server.APIError{Code: server.ErrForbidden, Details: "Invalid password"}
|
|
}
|
|
|
|
ticket := common.RandBase64(48)
|
|
err := s.DB.Redis.Do(ctx, radix.Cmd(nil, "SET",
|
|
emailChangeTicketKey(ticket), emailChangeTicketValue(req.Email, u.SnowflakeID), "EX", "3600"))
|
|
if err != nil {
|
|
return errors.Wrap(err, "setting email change key")
|
|
}
|
|
|
|
// if the email address already exists, pretend we sent an email and return
|
|
exists, err := s.DB.EmailExistsTx(ctx, tx, req.Email)
|
|
if err != nil {
|
|
return errors.Wrap(err, "checking if email exists")
|
|
}
|
|
if exists {
|
|
render.NoContent(w, r)
|
|
return nil
|
|
}
|
|
|
|
go s.SendEmail(req.Email, "Confirm your email address", "change", map[string]any{
|
|
"Ticket": ticket,
|
|
"Username": u.Username,
|
|
})
|
|
|
|
render.NoContent(w, r)
|
|
return nil
|
|
}
|
|
|
|
type putEmailConfirmRequest struct {
|
|
Ticket string `json:"ticket"`
|
|
}
|
|
|
|
type putEmailConfirmResponse struct {
|
|
Email string `json:"email"`
|
|
User userResponse `json:"user"`
|
|
}
|
|
|
|
func (s *Server) putEmailConfim(w http.ResponseWriter, r *http.Request) (err error) {
|
|
ctx := r.Context()
|
|
var req putEmailConfirmRequest
|
|
err = render.Decode(r, &req)
|
|
if err != nil {
|
|
return server.APIError{Code: server.ErrBadRequest}
|
|
}
|
|
|
|
var ticket string
|
|
err = s.DB.Redis.Do(ctx, radix.Cmd(&ticket, "GET", emailChangeTicketKey(req.Ticket)))
|
|
if err != nil {
|
|
return errors.Wrap(err, "getting email change key")
|
|
}
|
|
if ticket == "" {
|
|
return server.APIError{Code: server.ErrBadRequest, Details: "Unknown ticket"}
|
|
}
|
|
|
|
email, userID, ok := parseEmailChangeTicket(ticket)
|
|
if !ok {
|
|
return fmt.Errorf("invalid email change ticket %q", ticket)
|
|
}
|
|
|
|
u, err := s.DB.UserBySnowflake(ctx, userID)
|
|
if err != nil {
|
|
return errors.Wrap(err, "getting user")
|
|
}
|
|
|
|
tx, err := s.DB.Begin(ctx)
|
|
if err != nil {
|
|
return errors.Wrap(err, "beginning transaction")
|
|
}
|
|
defer func() {
|
|
_ = tx.Rollback(ctx)
|
|
}()
|
|
|
|
err = s.DB.RemoveEmails(ctx, tx, userID)
|
|
if err != nil {
|
|
return errors.Wrapf(err, "removing existing email addresses for user %v", userID)
|
|
}
|
|
|
|
dbEmail, err := s.DB.AddEmail(ctx, s.DB, userID, email)
|
|
if err != nil {
|
|
if err == db.ErrEmailInUse {
|
|
// This should only happen if the email was *not* taken when the ticket was sent, but was taken in the meantime.
|
|
// i.e. unless another person has access to the mailbox, the user will know what happened
|
|
return server.APIError{Code: server.ErrBadRequest, Details: "Email is already in use"}
|
|
}
|
|
|
|
return errors.Wrap(err, "adding email to user")
|
|
}
|
|
|
|
render.JSON(w, r, putEmailConfirmResponse{
|
|
Email: dbEmail.EmailAddress,
|
|
User: *dbUserToUserResponse(u, nil),
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func emailChangeTicketKey(ticket string) string {
|
|
return "email-change:" + ticket
|
|
}
|
|
|
|
func emailChangeTicketValue(email string, userID common.UserID) string {
|
|
return email + ":" + userID.String()
|
|
}
|
|
|
|
func parseEmailChangeTicket(v string) (email string, userID common.UserID, ok bool) {
|
|
before, after, ok := strings.Cut(v, ":")
|
|
if !ok {
|
|
return "", common.UserID(common.NullSnowflake), false
|
|
}
|
|
|
|
id, err := common.ParseSnowflake(after)
|
|
if err != nil {
|
|
return "", common.UserID(common.NullSnowflake), false
|
|
}
|
|
|
|
return before, common.UserID(id), true
|
|
}
|