Files
galaxy-game/backend/internal/auth/challenge.go
T
Ilia Denisov 859b157a59
Tests · Go / test (pull_request) Successful in 2m9s
Tests · Go / test (push) Successful in 2m9s
Tests · Integration / integration (pull_request) Successful in 1m49s
Tests · UI / test (pull_request) Successful in 2m51s
auth dev-fixed-code bypasses attempts cap; dev-deploy gains manual dispatch
Two problems showed up while trying to log into the long-lived dev
environment with the dev-fixed code `123456`:

1. `ConfirmEmailCode` checked the per-challenge attempts ceiling
   *before* the dev-fixed-code override. A developer who burned past
   `ChallengeMaxAttempts` on an existing un-consumed challenge (easy
   to trigger when the throttle reuses one challenge_id) hit
   `ErrTooManyAttempts` and the UI rendered "code expired or already
   used" even though the fixed code was correct. Reorder so the
   dev-fixed-code branch runs first and bypasses both the bcrypt
   verify and the attempts gate. Production stays unaffected
   because production loaders refuse to set `DevFixedCode`.

2. `dev-deploy.yaml` only fires on push to `development`, so the
   matching docker-compose default change for
   `BACKEND_AUTH_DEV_FIXED_CODE` could not reach the running stack
   before this PR merged. Add `workflow_dispatch: {}` so a developer
   can deploy any branch — typically a feature branch under review —
   from the Gitea Actions UI without waiting for the merge.

Covered by a new `TestConfirmEmailCodeDevFixedCodeBypassesAttemptsCeiling`
integration test that burns through the ceiling with wrong codes
then proves the dev-fixed code still produces a session.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 21:28:30 +02:00

294 lines
9.9 KiB
Go

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
}