Files
galaxy-game/backend/internal/auth/challenge.go
T
Ilia Denisov 69fa6b30e1 tools/local-dev: docker-compose stack for UI development
Adds tools/local-dev/ with postgres + redis + mailpit + backend +
gateway plus a Make wrapper, so `make -C tools/local-dev up` brings
the full authenticated stack online and `pnpm -C ui/frontend dev`
talks to it directly. The committed `.env.development` already
points at the stack and pins the matching gateway response public
key from the dev keypair under tools/local-dev/keys/.

The backend ships a new opt-in env, BACKEND_AUTH_DEV_FIXED_CODE
(`tools/local-dev/.env` defaults it to 123456). When set,
ConfirmEmailCode accepts that literal in addition to the real
bcrypt-verified code; SendEmailCode still queues a real email so
Mailpit captures the issued code at http://localhost:8025/, and
both paths coexist. The override is rejected as non-six-digit by
config validation and emits a loud warning at backend startup.

The local-dev Dockerfiles mirror backend/Dockerfile and
gateway/Dockerfile but switch the runtime stage to alpine so
docker-compose healthchecks can wget /healthz; the gateway
Dockerfile additionally copies ui/core/ into the build context
because gateway/go.mod's `replace galaxy/core => ../ui/core` is
required to compile the gateway main.

Smoke tested:
- `make -C tools/local-dev up` boots all five services to healthy.
- send-email-code + confirm-email-code with code=123456 returns a
  device_session_id; a real code in Mailpit also redeems
  successfully.
- `pnpm test` 14/14, `pnpm exec playwright test` 44/44.
- `go test ./backend/internal/config/...` green.

Docs: tools/local-dev/README.md, tools/local-dev/keys/README.md,
new "Local development stack" section in ui/docs/testing.md, and a
short pointer in ui/README.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 09:42:29 +02:00

285 lines
9.2 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
}
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 !s.devFixedCodeMatches(in.Code) {
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
}
} else {
s.deps.Logger.Warn("auth challenge accepted via dev-mode fixed code override",
zap.String("challenge_id", in.ChallengeID.String()),
)
}
// 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
}