Files
warmbly/internal/app/auth/reset_password.go
T
2026-09-21 04:56:50 -07:00

286 lines
9.9 KiB
Go

package auth
import (
"context"
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
tokenpkg "github.com/warmbly/warmbly/internal/app/token"
"github.com/warmbly/warmbly/internal/config"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/notify/templates"
"github.com/warmbly/warmbly/internal/observability/errs"
"github.com/warmbly/warmbly/internal/pkg/argon2"
"github.com/warmbly/warmbly/internal/pkg/crypt"
)
func (s *authService) ResetPasswordStart(ctx context.Context, data *ResetPasswordStart, ipaddr string) *errx.Error {
// The caller's 400, not an incident. See LoginStart.
if err := s.captcha.Verify(ctx, data.Turnstile, ipaddr); err != nil {
return err
}
// Before the budget as well as the lookup, so the same address typed two
// ways spends one budget rather than two.
data.Email = normalizeEmail(data.Email)
// Spend the budget before the lookup, and key it on the submitted address,
// so an unknown address costs the attacker the same as a known one.
if err := s.passwordResetLimit(ctx, data.Email); err != nil {
return err
}
xerr := s.startPasswordReset(ctx, data)
// A failure here produced no mail, so it does not spend the address's
// allowance. The budget is two requests per four hours: charging our own
// faults to it meant one bad afternoon locked a real person out of the
// only self-service way back into their account, and the second attempt
// failed for a different reason than the first. An unknown address
// deliberately still pays, because it returns nil rather than an error.
if xerr != nil {
s.refundPasswordResetLimit(ctx, data.Email)
}
return xerr
}
func (s *authService) startPasswordReset(ctx context.Context, data *ResetPasswordStart) *errx.Error {
user, uerr := s.userRepository.GetUserByEmail(ctx, data.Email)
if uerr != nil {
// Only "no such account" is answered 200. Returning ErrUser here was an
// enumeration oracle, but swallowing EVERY error into one was worse: a
// cache or database fault answered "Email successfully sent." and sent
// nothing, which is indistinguishable to the person from a mail that
// was delivered to a folder they cannot find. Anything that is not the
// address being unknown is ours, and says so.
if !errors.Is(uerr, errx.ErrUser) {
errs.CaptureException(uerr)
return errx.InternalError()
}
// Logged because this one really does answer 200: without a line here
// a reset that reached nobody left no trace anywhere, so a mistyped
// address and a broken transport looked identical from support.
log.Info().Str("email", data.Email).Msg("password reset requested for an address with no account")
return nil
}
u, xerr := s.userService.GetUser(ctx, user.ID)
if xerr != nil {
// Same reasoning: the account exists, so this is a cache or database
// failure and never an unknown address. Reported rather than hidden
// behind a success — this is the path a Redis quota outage took.
errs.CaptureException(xerr)
return errx.InternalError()
}
sessionID := uuid.New()
nonce, err := crypt.Nonce()
if err != nil {
errs.CaptureException(err)
return errx.InternalError()
}
issuedAt := time.Now()
expiresAt := issuedAt.Add(PasswordResetTTL)
token, err := s.tokenService.GenerateTokenFor(tokenpkg.PurposePasswordReset, user.ID, sessionID, data.Email, nonce, issuedAt, expiresAt)
if err != nil {
errs.CaptureException(err)
return errx.InternalError()
}
if err := s.saveResetPasswordSession(ctx, sessionID, nonce); err != nil {
return err
}
url := config.GetPasswordResetURL(token)
text, err := templates.GenerateResetPasswordHTML(u.FirstName, url, PasswordResetTTL)
if err != nil {
errs.CaptureException(err)
return errx.InternalError()
}
// Reported by the transport; see LoginStart.
if err := s.sendResetEmailWithRetry(ctx, u.Email, "Password Reset Confirmation", text); err != nil {
return errx.ErrMailUndeliverable
}
return nil
}
// sendResetEmailWithRetry makes one transient failure survivable rather than
// final. The reset mail is the only self-service way back into an account, so
// a single refused connection or throttled SES call should cost a second of
// latency, not the whole attempt. Bounded to one retry and a short pause: the
// caller is a person holding an HTTP request open, and a rejection that is
// going to be permanent (an unverified identity, a suppressed address) repeats
// identically, so there is nothing to gain from trying harder.
func (s *authService) sendResetEmailWithRetry(ctx context.Context, to, subject, message string) error {
err := s.sendAuthEmail(ctx, to, subject, message)
if err == nil {
return nil
}
// Nothing left to retry into: the caller gave up or the deadline passed.
if ctx.Err() != nil {
return err
}
select {
case <-time.After(authEmailRetryDelay):
case <-ctx.Done():
return err
}
return s.sendAuthEmail(ctx, to, subject, message)
}
func (s *authService) ResetPasswordConfirm(ctx context.Context, data *ResetPasswordConfirm, session, ipaddr string) *errx.Error {
// The caller's 400, not an incident. See LoginStart.
if err := s.captcha.Verify(ctx, data.Turnstile, ipaddr); err != nil {
return err
}
sess, err := s.tokenService.VerifyTokenFor(tokenpkg.PurposePasswordReset, session)
if err != nil {
return err
}
if sess.ExpiresAt.Before(time.Now()) {
return errx.ErrToken
}
nonce, err := s.getResetPasswordSession(ctx, sess.SessionID)
if err != nil {
return err
}
if nonce != sess.Nonce {
return errx.ErrToken
}
// A link is only good for the password it was requested against. Once the
// password has been written by any path (this flow, the signed-in change,
// the operator CLI), every link issued before that write is dead, however
// long its own expiry has left.
changedAt, err := s.authRepository.PasswordChangedAt(ctx, sess.UserID)
if err != nil {
return err
}
if resetLinkPredatesPassword(sess.IssuedAt, changedAt) {
return errx.ErrToken
}
if err := s.deletePasswordResetSession(ctx, sess.SessionID); err != nil {
return err
}
// Proving control of the mailbox clears any lockout that wrong passwords
// accumulated, so a person who was locked out is not still locked out after
// resetting, and an attacker cannot keep the lock on by guessing.
s.clearLoginFailures(ctx, normalizeEmail(sess.Email))
if perr := crypt.PasswordError(data.Password); perr != nil {
return perr
}
passwordHash, hashErr := argon2.Hash(data.Password)
if hashErr != nil {
errs.CaptureException(hashErr)
return errx.InternalError()
}
if err := s.authRepository.ResetPassword(ctx, sess.UserID, passwordHash); err != nil {
return err
}
// A forgotten-password reset means the account may be compromised: evict
// every existing session (no current device to keep — uuid.Nil matches
// none, so all are revoked) so a reset always fully cuts off prior access.
if s.tokenService != nil {
if err := s.tokenService.RevokeOtherSessions(ctx, sess.UserID, uuid.Nil); err != nil {
errs.CaptureException(err)
// Non-fatal: the password is already reset.
}
}
return nil
}
// resetLinkPredatesPassword reports whether a reset token was issued no later
// than the last password write. JWT iat is whole seconds and the write is
// stamped by Postgres at microseconds, so a token minted in the same second as
// the change is refused too: fail closed, the person asks for a new link.
func resetLinkPredatesPassword(issuedAt *jwt.NumericDate, changedAt *time.Time) bool {
if changedAt == nil {
return false
}
if issuedAt == nil {
return true
}
return !issuedAt.Time.After(*changedAt)
}
// ErrPasswordChangedSignInAgain answers a change whose password is already
// stored when the device could not be given a new session. The client must
// not keep its old tokens and must sign in again with the new password.
var ErrPasswordChangedSignInAgain = errx.NewWithIdentifier(errx.Conflict, "password_changed_sign_in_again",
"Your password was changed, but this device could not be signed back in. Sign in again with your new password.")
// ChangePassword updates a logged-in user's password. It verifies the current
// password first (so a hijacked but unattended session can't silently change
// it), rejects OAuth-only accounts, and enforces the password policy. Every
// session ends with the change; the caller gets a new pair for its device.
func (s *authService) ChangePassword(ctx context.Context, userID uuid.UUID, current *models.Session, ipaddr, userAgent string, data *ChangePassword) (*models.Token, *errx.Error) {
hash, xerr := s.authRepository.GetPasswordHash(ctx, userID)
if xerr != nil {
return nil, xerr
}
if hash == "" {
return nil, errx.New(errx.BadRequest, "this account signs in without a password")
}
ok, verr := argon2.Verify(data.CurrentPassword, hash)
if verr != nil {
errs.CaptureException(verr)
return nil, errx.InternalError()
}
if !ok {
return nil, errx.ErrCredentials
}
if perr := crypt.PasswordError(data.NewPassword); perr != nil {
return nil, perr
}
if data.NewPassword == data.CurrentPassword {
return nil, errx.New(errx.BadRequest, "the new password must be different")
}
newHash, hashErr := argon2.Hash(data.NewPassword)
if hashErr != nil {
errs.CaptureException(hashErr)
return nil, errx.InternalError()
}
if err := s.authRepository.ResetPassword(ctx, userID, newHash); err != nil {
return nil, err
}
if s.tokenService == nil {
return nil, nil
}
// The password is stored by now, so this failure is its own outcome, not
// an ordinary error: the caller must drop its tokens and sign in again.
tok, err := s.tokenService.ReissueSession(ctx, userID, current, ipaddr, userAgent)
if err != nil {
errs.CaptureException(err)
return nil, ErrPasswordChangedSignInAgain
}
return tok, nil
}
// PasswordHashFor returns the stored argon2 hash for a user.
func (s *authService) PasswordHashFor(ctx context.Context, userID uuid.UUID) (string, *errx.Error) {
return s.authRepository.GetPasswordHash(ctx, userID)
}