feat: password reset

This commit is contained in:
Krispire Admin
2026-06-02 18:37:08 +05:30
parent ac95a857bd
commit cf3a49458a
8 changed files with 303 additions and 39 deletions
+150 -31
View File
@@ -164,12 +164,6 @@ type OAuthProfile struct {
AvatarURL string
}
// ErrOAuthEmailMissing is returned by SignInWithOAuth when the provider did
// not give us an email and the account has no linkage we could match on.
// Without an email we can't safely create a new user (since later sign-ins
// via email/password would have no way to converge with this account).
var ErrOAuthEmailMissing = errors.New("oauth provider did not return an email")
// ErrOAuthUnavailable means the OAuth repo wasn't wired (no Postgres at
// startup). Handler maps to 503.
var ErrOAuthUnavailable = errors.New("oauth service unavailable")
@@ -177,13 +171,19 @@ var ErrOAuthUnavailable = errors.New("oauth service unavailable")
// SignInWithOAuth turns a provider profile into a session. Three-step lookup:
//
// 1. Existing linkage on (provider, provider_user_id) → log that user in.
// 2. Existing user with same email → link this provider to them, log in.
// 3. Otherwise create a new user (no password, email_verified) and link.
// 2. Provider gave us an email AND an existing user has the same email →
// link this provider to them, log in.
// 3. Otherwise create a new user (no password, email_verified iff we have
// an email) and link.
//
// Every successful OAuth round-trip is treated as proof the user controls
// the email address — Google + Facebook both verify email ownership before
// returning it to us, so we always mark the email as verified on sign-in
// regardless of the provider's `email_verified` flag.
// When the provider returns no email (Facebook accounts with no verified
// email on file, accounts that declined the email scope, etc.) we skip
// the merge step and create the user with email=NULL. They can add an
// email later via PATCH /users/me — the frontend prompts them.
//
// When we DO have an email, every successful OAuth round-trip is treated
// as proof the user controls it — Google + Facebook both verify email
// ownership before returning it, so we mark the email as verified.
//
// In every case the resulting user gets a fresh JWT just like LogIn.
func (s *AuthService) SignInWithOAuth(ctx context.Context, prof OAuthProfile) (*AuthResult, error) {
@@ -201,36 +201,47 @@ func (s *AuthService) SignInWithOAuth(ctx context.Context, prof OAuthProfile) (*
// avatar may have changed at the provider).
_, _ = s.oauth.Link(ctx, u.ID, prof.Provider, prof.ProviderUserID,
optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL))
s.ensureEmailVerified(ctx, u)
if prof.Email != "" {
s.ensureEmailVerified(ctx, u)
}
return s.issueSession(u)
} else if !errors.Is(err, repository.ErrOAuthAccountNotFound) {
return nil, err
}
// No linkage yet — we need at least an email to safely converge with an
// existing account or create a new one.
if prof.Email == "" {
return nil, ErrOAuthEmailMissing
}
// 2. Existing user with the same email → link.
if u, err := s.users.FindByEmail(ctx, prof.Email); err == nil {
if _, err := s.oauth.Link(ctx, u.ID, prof.Provider, prof.ProviderUserID,
optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL)); err != nil {
// Only attempt this when the provider actually gave us an email. With
// no email there's no merge key — we just create a fresh user below.
if prof.Email != "" {
if u, err := s.users.FindByEmail(ctx, prof.Email); err == nil {
if _, err := s.oauth.Link(ctx, u.ID, prof.Provider, prof.ProviderUserID,
optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL)); err != nil {
return nil, err
}
s.ensureEmailVerified(ctx, u)
return s.issueSession(u)
} else if !errors.Is(err, repository.ErrUserNotFound) {
return nil, err
}
s.ensureEmailVerified(ctx, u)
return s.issueSession(u)
} else if !errors.Is(err, repository.ErrUserNotFound) {
return nil, err
}
// 3. Brand new user. Reuse the handle-collision retry loop.
displayName := prof.Name
if displayName == "" {
if displayName == "" && prof.Email != "" {
displayName = strings.SplitN(prof.Email, "@", 2)[0]
}
if displayName == "" {
// No name AND no email — fall back to the provider name so the user
// at least has a non-empty display name they can rename later.
displayName = strings.Title(prof.Provider) + " user" //nolint:staticcheck // legacy Title is fine for ASCII labels
}
base := slugForHandle(prof.Email)
if base == "user" && prof.ProviderUserID != "" {
// slugForHandle returns "user" for empty input — prefer something
// less collision-prone for OAuth-no-email signups.
base = "user-" + safeSlugSuffix(prof.ProviderUserID)
}
emailVerified := prof.Email != "" // can only be verified if we have an email
var u *model.User
var err error
for attempt := 0; attempt < 8; attempt++ {
@@ -238,13 +249,13 @@ func (s *AuthService) SignInWithOAuth(ctx context.Context, prof OAuthProfile) (*
if attempt > 0 {
handle = fmt.Sprintf("%s-%s", base, randomHandleSuffix())
}
// Always create as verified — successful OAuth = email ownership proof.
u, err = s.users.CreateOAuth(ctx, prof.Email, displayName, handle, optStr(prof.AvatarURL), true)
u, err = s.users.CreateOAuth(ctx, optStr(prof.Email), displayName, handle, optStr(prof.AvatarURL), emailVerified)
if err == nil {
break
}
if errors.Is(err, repository.ErrEmailTaken) {
// Race: someone else just claimed this email. Fall back to step 2.
// Only reachable when prof.Email != "" (NULL emails never collide).
if u2, err2 := s.users.FindByEmail(ctx, prof.Email); err2 == nil {
_, _ = s.oauth.Link(ctx, u2.ID, prof.Provider, prof.ProviderUserID,
optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL))
@@ -268,6 +279,30 @@ func (s *AuthService) SignInWithOAuth(ctx context.Context, prof OAuthProfile) (*
return s.issueSession(u)
}
// safeSlugSuffix reduces an arbitrary provider id to a short ASCII tail
// suitable as part of a handle. Provider ids are typically all-digit
// (Facebook) or short ASCII (Google sub), so a trailing 8-char window is
// plenty and avoids handles getting silly-long.
func safeSlugSuffix(s string) string {
out := strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
return r
case r >= 'A' && r <= 'Z':
return r + ('a' - 'A')
default:
return -1
}
}, s)
if len(out) > 8 {
out = out[len(out)-8:]
}
if out == "" {
return randomHandleSuffix()
}
return out
}
// optStr returns nil for empty input, &s otherwise — used to populate
// nullable text columns from possibly-empty profile fields.
func optStr(s string) *string {
@@ -277,6 +312,78 @@ func optStr(s string) *string {
return &s
}
// ChangePassword sets the caller's password, in two modes:
//
// 1. Account HAS a password — caller must supply the current one. We
// verify with bcrypt before replacing, defending against an attacker
// who got hold of the session cookie but doesn't know the password.
// 2. Account has NO password yet (OAuth-only signup) — caller can set
// one without proving anything beyond their existing session. There's
// no current password to verify against, and the session already
// grants them everything the new password would, so requiring an
// email round-trip here would only be friction.
//
// In both cases token_version is bumped (invalidating every JWT ever
// issued to this user) and a fresh session is re-issued so the calling
// device stays logged in while every other device is signed out.
func (s *AuthService) ChangePassword(ctx context.Context, userID, currentPassword, newPassword string) (*AuthResult, error) {
if len(newPassword) < 8 {
return nil, ErrWeakPassword
}
u, err := s.users.FindByID(ctx, userID)
if err != nil {
return nil, ErrUserNotFound
}
if u.PasswordHash != nil {
// Account has a password — current must match before we'll replace it.
if err := bcrypt.CompareHashAndPassword([]byte(*u.PasswordHash), []byte(currentPassword)); err != nil {
return nil, ErrInvalidCredentials
}
}
// If u.PasswordHash == nil we skip the verification: setting an initial
// password for an OAuth-only account doesn't require proving anything
// beyond the active session.
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("hash password: %w", err)
}
if err := s.users.UpdatePassword(ctx, userID, string(hash)); err != nil {
return nil, err
}
// Re-fetch to pick up the new token_version so the freshly-issued JWT
// has the right "tv" claim and the cookie set after this call keeps
// the user logged in on this device.
u2, err := s.users.FindByID(ctx, userID)
if err != nil {
return nil, err
}
return s.issueSession(u2)
}
// SetEmail fills in (or replaces) the caller's email address. Used by the
// "add your email" flow for OAuth users who signed up without one, and by
// any user who later wants to change their address.
//
// The address is stored as unverified — a verification link is sent to it
// immediately. Returns ErrEmailAlreadyTaken if another row already owns it.
func (s *AuthService) SetEmail(ctx context.Context, userID, email string) (*model.User, error) {
u, err := s.users.SetEmail(ctx, userID, email)
if err != nil {
if errors.Is(err, repository.ErrEmailTaken) {
return nil, ErrEmailAlreadyTaken
}
return nil, err
}
if err := s.sendVerificationEmail(ctx, u); err != nil {
// Verification email failure shouldn't block the set — the user can
// always trigger a resend.
s.logger.Warn("verification email failed after SetEmail",
zap.String("user", u.ID), zap.Error(err))
}
return u, nil
}
// ensureEmailVerified flips email_verified_at to now() on the given user
// if it's not already set. Called from every SignInWithOAuth branch so a
// successful OAuth round-trip immediately satisfies email verification
@@ -331,9 +438,15 @@ func (s *AuthService) RequestPasswordReset(ctx context.Context, email string) er
return err
}
// OAuth-only users may have no email on file — skip the send silently
// (the caller already cannot tell whether the email was registered, so
// "no email to send to" surfaces the same way).
if u.Email == nil || *u.Email == "" {
return nil
}
link := fmt.Sprintf("%s/reset-password?token=%s", s.frontend, raw)
body := passwordResetEmail(u.Name, link)
if err := s.mail.Send(u.Email, "Reset your Prabhupāda Says password", body); err != nil {
if err := s.mail.Send(*u.Email, "Reset your Prabhupāda Says password", body); err != nil {
s.logger.Warn("password reset email failed", zap.String("user", u.ID), zap.Error(err))
}
return nil
@@ -408,6 +521,12 @@ func (s *AuthService) issueSession(u *model.User) (*AuthResult, error) {
}
func (s *AuthService) sendVerificationEmail(ctx context.Context, u *model.User) error {
// Users with no email on file (OAuth signups where the provider didn't
// return one) have nothing to send to. The caller is expected to gate
// on this, but defensively no-op here too.
if u.Email == nil || *u.Email == "" {
return nil
}
_ = s.tokens.InvalidatePending(ctx, u.ID, model.PurposeEmailVerification)
raw, hash, err := newToken()
@@ -420,7 +539,7 @@ func (s *AuthService) sendVerificationEmail(ctx context.Context, u *model.User)
link := fmt.Sprintf("%s/verify-email?token=%s", s.frontend, raw)
body := verifyEmailMessage(u.Name, link)
return s.mail.Send(u.Email, "Verify your email — Prabhupāda Says", body)
return s.mail.Send(*u.Email, "Verify your email — Prabhupāda Says", body)
}
// newToken returns (rawHex, sha256HexHash). The raw value goes in the