package auth import ( "context" "database/sql" "errors" "fmt" "strings" "github.com/google/uuid" "go.uber.org/zap" ) // SendEmailCode issues an email login challenge for email and returns // its challenge_id. The wire shape is intentionally identical for new // users, existing users, and throttled requesters; the only path that // returns ErrEmailPermanentlyBlocked is when email maps to an account // whose `permanent_block` column is true (handler maps that sentinel to // 400 invalid_request). // // Throttle behaviour: when the count of un-consumed, non-expired // challenges for email created within ChallengeThrottle.Window already // equals or exceeds ChallengeThrottle.Max, SendEmailCode reuses the // most recent existing challenge_id and skips the mail enqueue. This // avoids a leak where an attacker who controls their own SMTP server // could otherwise correlate "row created without mail" with // throttle-state on the platform. // // locale (request body, BCP 47) takes precedence over acceptLanguage // (the standard HTTP header forwarded by gateway) when both are // supplied. When neither is supplied SendEmailCode falls back to the // platform default ("en"). The resolved value is persisted on the // challenge row as `preferred_language` and used by confirm-email-code // only for newly-registered accounts; existing accounts keep their // stored language. func (s *Service) SendEmailCode( ctx context.Context, email, locale, acceptLanguage, sourceIP string, ) (uuid.UUID, error) { normalised := normaliseEmail(email) if normalised == "" { return uuid.Nil, fmt.Errorf("auth: email is empty") } permanent, err := s.deps.Store.IsEmailPermanentlyBlocked(ctx, normalised) if err != nil { return uuid.Nil, err } if permanent { return uuid.Nil, ErrEmailPermanentlyBlocked } captured := pickCapturedLocale(locale, acceptLanguage) if captured == "" { captured = defaultLanguage } now := s.deps.Now() windowStart := now.Add(-s.deps.Config.ChallengeThrottle.Window) count, err := s.deps.Store.CountRecentChallenges(ctx, normalised, windowStart) if err != nil { return uuid.Nil, err } if count >= s.deps.Config.ChallengeThrottle.Max { existing, lerr := s.deps.Store.LatestUnconsumedChallenge(ctx, normalised, windowStart) if lerr == nil { s.deps.Logger.Info("auth challenge reused (throttled)", zap.String("email_hash", s.hashEmail(normalised)), zap.String("challenge_id", existing.ChallengeID.String()), zap.Int("recent_count", count), ) return existing.ChallengeID, nil } if !errors.Is(lerr, sql.ErrNoRows) { return uuid.Nil, lerr } // sql.ErrNoRows here is a race (a concurrent confirm consumed // the row between count and select); fall through and issue a // fresh challenge. } code, err := generateCode() if err != nil { return uuid.Nil, err } hash, err := hashCode(code) if err != nil { return uuid.Nil, fmt.Errorf("auth: hash code: %w", err) } challenge := Challenge{ ChallengeID: uuid.New(), Email: normalised, CodeHash: hash, ExpiresAt: now.Add(s.deps.Config.ChallengeTTL), PreferredLanguage: captured, } if err := s.deps.Store.InsertChallenge(ctx, challenge); err != nil { return uuid.Nil, err } if err := s.deps.Mail.EnqueueLoginCode(ctx, normalised, code, s.deps.Config.ChallengeTTL); err != nil { // A mail-enqueue failure is logged but not surfaced — the user // can issue another challenge. The implementation will surface a // transient error path; for The implementation the no-op publisher never // returns an error. s.deps.Logger.Warn("auth: enqueue login code failed", zap.String("email_hash", s.hashEmail(normalised)), zap.String("challenge_id", challenge.ChallengeID.String()), zap.Error(err), ) } s.deps.Logger.Info("auth challenge issued", zap.String("email_hash", s.hashEmail(normalised)), zap.String("challenge_id", challenge.ChallengeID.String()), ) return challenge.ChallengeID, nil } // ConfirmInputs is the parsed-and-validated input to ConfirmEmailCode. // Wire-format validation (base64 decode, 32-byte length, IANA time-zone // parse, source-IP extraction) happens at the handler boundary so the // service operates on already-typed values. type ConfirmInputs struct { ChallengeID uuid.UUID Code string ClientPublicKey []byte TimeZone string SourceIP string } // ConfirmEmailCode redeems a challenge_id, ensures the corresponding // `accounts` row exists, and creates an active `device_sessions` row. // The returned Session is identical to the row stored in the database // (including server-assigned timestamps). // // The flow runs in two transactions: // // 1. LoadAndIncrementChallenge increments the attempts counter under // SELECT FOR UPDATE so concurrent attempts cannot bypass the ceiling. // 2. Out-of-band: ceiling check, bcrypt verify, EnsureByEmail. // 3. MarkConsumedAndInsertSession atomically marks the challenge // consumed and inserts the device_session row, satisfying the // "single challenge → at most one session" invariant. // // Post-commit work (cache write-through, declared_country backfill) is // best-effort: a failure does not roll the registration back. func (s *Service) ConfirmEmailCode(ctx context.Context, in ConfirmInputs) (Session, error) { if in.ChallengeID == uuid.Nil { return Session{}, ErrChallengeNotFound } if len(in.ClientPublicKey) != 32 { return Session{}, fmt.Errorf("auth: client public key must be 32 bytes, got %d", len(in.ClientPublicKey)) } if strings.TrimSpace(in.TimeZone) == "" { return Session{}, fmt.Errorf("auth: time_zone must not be empty") } loaded, err := s.deps.Store.LoadAndIncrementChallenge(ctx, in.ChallengeID) if err != nil { return Session{}, err } // The dev-mode fixed-code override is checked first so it bypasses // both the bcrypt verify and the per-challenge attempts ceiling. // Without this, a developer who already burned through // `ChallengeMaxAttempts` on an existing un-consumed challenge — // for example after the throttle merged repeated send-email-code // calls onto one challenge_id — could not recover with the fixed // code either, defeating the purpose of the override. Production // deployments leave `DevFixedCode` empty, so this branch is // inert and the regular attempts gate still applies. if s.devFixedCodeMatches(in.Code) { s.deps.Logger.Warn("auth challenge accepted via dev-mode fixed code override", zap.String("challenge_id", in.ChallengeID.String()), zap.Int32("attempts", loaded.Attempts), ) } else { if int(loaded.Attempts) > s.deps.Config.ChallengeMaxAttempts { s.deps.Logger.Info("auth challenge attempts exhausted", zap.String("challenge_id", in.ChallengeID.String()), zap.Int32("attempts", loaded.Attempts), ) return Session{}, ErrTooManyAttempts } if err := verifyCode(loaded.CodeHash, in.Code); err != nil { if errors.Is(err, ErrCodeMismatch) { s.deps.Logger.Info("auth challenge code mismatch", zap.String("challenge_id", in.ChallengeID.String()), zap.Int32("attempts", loaded.Attempts), ) return Session{}, ErrCodeMismatch } return Session{}, err } } // Re-check permanent_block after verifying the code. SendEmailCode // guards against fresh challenges for already-blocked addresses; // this guard catches the case where an admin applied // permanent_block in the window between send and confirm. permanent, err := s.deps.Store.IsEmailPermanentlyBlocked(ctx, loaded.Email) if err != nil { return Session{}, fmt.Errorf("auth: check permanent block at confirm: %w", err) } if permanent { return Session{}, ErrEmailPermanentlyBlocked } preferredLang := loaded.PreferredLanguage if preferredLang == "" { // Defensive fallback: SendEmailCode now always persists a // non-empty preferred_language, but a row written by an older // build could still be empty. preferredLang = defaultLanguage } declaredCountry := s.deps.Geo.LookupCountry(in.SourceIP) userID, err := s.deps.User.EnsureByEmail(ctx, loaded.Email, preferredLang, in.TimeZone, declaredCountry) if err != nil { return Session{}, fmt.Errorf("auth: ensure account by email: %w", err) } deviceSessionID := uuid.New() pending := Session{ DeviceSessionID: deviceSessionID, UserID: userID, Status: SessionStatusActive, ClientPublicKey: cloneBytes(in.ClientPublicKey), } if err := s.deps.Store.MarkConsumedAndInsertSession(ctx, in.ChallengeID, pending); err != nil { return Session{}, err } persisted, err := s.deps.Store.LoadSession(ctx, deviceSessionID) if err != nil { return Session{}, fmt.Errorf("auth: reload created session: %w", err) } s.deps.Cache.Add(persisted) if err := s.deps.Geo.SetDeclaredCountryAtRegistration(ctx, userID, in.SourceIP); err != nil { s.deps.Logger.Warn("auth: declared country backfill failed", zap.String("user_id", userID.String()), zap.Error(err), ) } s.deps.Logger.Info("auth session created", zap.String("user_id", userID.String()), zap.String("device_session_id", deviceSessionID.String()), ) return persisted, nil } // defaultLanguage is the fallback locale written when neither the body // nor the Accept-Language header nor the geoip-derived language produce // a value. const defaultLanguage = "en" func normaliseEmail(email string) string { return strings.ToLower(strings.TrimSpace(email)) } // pickCapturedLocale picks the locale to persist on the challenge row. // The body field wins over the header. The header parsing is // intentionally minimal — auth only stores the value, so a richer parse // would be wasted; user.Service treats the captured string as opaque. func pickCapturedLocale(locale, acceptLanguage string) string { if v := strings.TrimSpace(locale); v != "" { return v } if acceptLanguage == "" { return "" } first := acceptLanguage if idx := strings.IndexAny(first, ",;"); idx >= 0 { first = first[:idx] } return strings.TrimSpace(first) } func cloneBytes(b []byte) []byte { if b == nil { return nil } out := make([]byte, len(b)) copy(out, b) return out }