69fa6b30e1
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>
285 lines
9.2 KiB
Go
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
|
|
}
|