feat: password reset
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user