feat(api): add player email OTP verification (spec §B2 onboarding)

Forced web onboarding proves a player controls an email before it is
bound to their account. POST /api/v1/account/email/start mints a random
6-digit code, mails it (or logs it server-side when no Mailer is wired —
the demo has no SMTP), and POST /api/v1/account/email/verify redeems it,
flipping users.email_verified in the same transaction that consumes the
code.

Brute force is bounded two ways: a 10-minute TTL and a 5-attempt cap,
both enforced in the repo so the fake and Postgres agree. Only the
sha-256 of the code is stored; the digits live only in the email. Both
routes are app-tier external — verifying your own email is scoped to the
principal, never names another user.
This commit is contained in:
flyemoji committed 2026-06-27 12:29:52 +09:00
1 parent 2d0bbb0c37
commit dbe34a175f
9 files changed
+867 -4

No files matched your search

+87
View File
@@ -1444,6 +1444,93 @@ paths:
application/json:
schema: { $ref: '#/components/schemas/Error' }
/api/v1/account/email/start:
post:
tags: [account]
operationId: emailOtpStart
summary: Mint and deliver an email one-time code for the caller (web onboarding, spec §B2).
description: >
Generates a one-time code bound to the authenticated principal and the
supplied address, persists only its hash, and delivers it out of band. The
code is never returned in the response. A re-request supersedes the prior
unconsumed code.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [email]
properties:
email: { type: string, format: email }
responses:
'202':
description: Code minted and dispatched (or logged server-side when no mailer is wired).
content:
application/json:
schema:
type: object
required: [sent, expires_at]
properties:
sent: { type: boolean, const: true }
expires_at: { type: string, format: date-time }
'400':
description: Missing or malformed email address.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'401':
$ref: '#/components/responses/Unauthorized'
/api/v1/account/email/verify:
post:
tags: [account]
operationId: emailOtpVerify
summary: Redeem an email one-time code and mark the caller's email verified (spec §B2).
description: >
Consumes a previously delivered code for the authenticated principal. On
success the user's email is written and email_verified is set true. Too many
incorrect attempts lock the code (429); an unknown, expired, consumed, or
mismatched code is a 400.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [code]
properties:
code: { type: string }
responses:
'200':
description: Email verified.
content:
application/json:
schema:
type: object
required: [verified, email]
properties:
verified: { type: boolean, const: true }
email: { type: string, format: email }
'400':
description: Invalid or expired code.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'401':
$ref: '#/components/responses/Unauthorized'
'429':
description: Too many incorrect attempts; the code is locked.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
/api/v1/me/submissions:
post:
tags: [submissions]
+13
View File
@@ -66,6 +66,13 @@ type API struct {
// runs, distinct from Builder which an admin drives directly.
Submissions SubmissionService
// Mailer delivers player email one-time codes (spec §B2 onboarding). It is
// optional: when nil the email-OTP start route mints and persists the code but
// logs it server-side instead of mailing it (a KNOWN-LIMITATION — the demo has no
// SMTP), so the verify flow is still exercised end-to-end. Production wires a real
// sender. The code is never returned to the client on either path.
Mailer OTPMailer
// RootDomain is injected from config (spec §2). It is the only place the
// deployment zone enters the API; hostnames are validated against it and
// never hardcoded.
@@ -228,6 +235,12 @@ func (a *API) externalAPIRoutes() []apiRoute {
// authenticated operation.
{Method: "POST", Pattern: "/api/v1/account/link/start", h: a.handleLinkStart},
{Method: "POST", Pattern: "/api/v1/account/link/verify", h: a.handleLinkVerify},
// Email verification (spec §B2 onboarding), web side: /start mints+delivers a
// one-time code for the caller's chosen address, /verify redeems it and flips
// email_verified. App-tier like the link routes — proving control of your own
// email is an ordinary authenticated operation, scoped to the principal.
{Method: "POST", Pattern: "/api/v1/account/email/start", h: a.handleEmailOTPStart},
{Method: "POST", Pattern: "/api/v1/account/email/verify", h: a.handleEmailOTPVerify},
// Modpack submission (user-directed lane over §16), user side: a user files an upload for review
// and lists their own. App-tier — the submitter and the "my uploads" scope are
// both taken from the principal, never the body, so an ordinary authenticated
+69
View File
@@ -48,6 +48,24 @@ type fakeRepo struct {
staff map[string]*StaffUser // username -> staff login row
sessions map[string]*fakeSession // token_hash -> session
settings map[string][]byte // key -> jsonb value
// player email OTPs (spec §B2). Keyed by row id; the verify path scans for the
// newest live (user, purpose) just as the PG query does.
otps map[string]*fakeEmailOTP
}
// fakeEmailOTP mirrors an email_otps row: only the code hash is held (never the
// digits), attempts caps brute force, consumed marks single-use, and createdAt
// orders the newest-live lookup.
type fakeEmailOTP struct {
id string
userID string
email string
codeHash string
purpose string
attempts int
expiresAt time.Time
consumed bool
createdAt time.Time
}
// fakeSession mirrors a sessions row: its owner, its expiry, and whether it has
@@ -83,6 +101,7 @@ func newFakeRepo() *fakeRepo {
staff: map[string]*StaffUser{},
sessions: map[string]*fakeSession{},
settings: map[string][]byte{},
otps: map[string]*fakeEmailOTP{},
}
}
@@ -122,6 +141,56 @@ func (f *fakeRepo) VerifyLinkCode(_ context.Context, userID, code string, now ti
delete(f.linkCodes, code)
return rec.mcUUID, nil
}
// CreateEmailOTP / VerifyEmailOTP mirror PGRepo's contract so the hermetic tests
// exercise the same semantics the integration impl honors: a fresh code supersedes
// the prior live one for (user, purpose), expiry and the attempt cap are checked
// before the hash compare, a wrong guess costs an attempt without consuming the
// code, and a match consumes it and flips the user row verified.
func (f *fakeRepo) CreateEmailOTP(_ context.Context, id, userID, email, codeHash, purpose string, expiresAt time.Time) error {
for k, o := range f.otps { // supersede any prior live code (DELETE ... consumed_at IS NULL)
if o.userID == userID && o.purpose == purpose && !o.consumed {
delete(f.otps, k)
}
}
f.otps[id] = &fakeEmailOTP{
id: id, userID: userID, email: email, codeHash: codeHash, purpose: purpose,
expiresAt: expiresAt, createdAt: expiresAt, // createdAt proxy: constant TTL ⇒ later expiry == later creation
}
return nil
}
func (f *fakeRepo) VerifyEmailOTP(_ context.Context, userID, purpose, codeHash string, now time.Time) (string, error) {
var live *fakeEmailOTP
for _, o := range f.otps { // newest live (user, purpose)
if o.userID != userID || o.purpose != purpose || o.consumed {
continue
}
if live == nil || o.createdAt.After(live.createdAt) {
live = o
}
}
if live == nil {
return "", ErrOTPInvalid
}
if !live.expiresAt.After(now) {
return "", ErrOTPInvalid
}
if live.attempts >= otpMaxAttempts {
return "", ErrOTPLocked
}
if live.codeHash != codeHash {
live.attempts++ // a typo costs an attempt but does not consume the code
return "", ErrOTPInvalid
}
live.consumed = true
for _, u := range f.staff { // flip the user row verified (UPDATE users ...)
if u.ID == userID {
u.Email = live.email
u.EmailVerified = true
}
}
return live.email, nil
}
func (f *fakeRepo) UserInAllowlist(_ context.Context, n, u string) (bool, error) {
return f.allowlist[n][u], nil
}
+11
View File
@@ -25,6 +25,17 @@ var (
// actually up", not a server bug. Handlers map it to 503, not 500, so the
// caller is told to wake/retry rather than shown an opaque internal error.
ErrConsoleUnavailable = errors.New("server console is unavailable")
// ErrOTPInvalid means an email one-time code is unknown, expired, already
// consumed, or did not match (spec §B2 onboarding). Like ErrLinkCodeInvalid it
// is a client error — the verify endpoint exists; the code is bad — so handlers
// map it to 400, not 404. A wrong-but-not-yet-locked guess collapses to it too,
// so the response never distinguishes "no such code" from "wrong digits".
ErrOTPInvalid = errors.New("email code invalid or expired")
// ErrOTPLocked means the live email code has exhausted its attempt budget: too
// many wrong guesses (spec §B2). It is distinct from ErrOTPInvalid so handlers
// can answer 429 (back off / request a new code) rather than inviting another
// guess against a code that will never accept one.
ErrOTPLocked = errors.New("email code locked: too many attempts")
)
// apiError is a handler-level error carrying an HTTP status and a stable,
+202
View File
@@ -0,0 +1,202 @@
package api
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"log"
"math/big"
"net/http"
"strings"
"time"
)
// Player email verification (spec §B2 onboarding). Forced web onboarding proves a
// player controls an email before it is bound to their account: they request a
// one-time code, the platform mails it, and they type it back. Only a matching,
// unexpired, unconsumed code flips users.email_verified true. The two halves are
// app-tier external routes — verifying your OWN email is an ordinary authenticated
// operation, scoped entirely to the principal (the body never names a user).
//
// The code is a short numeric secret, so two independent defenses bound brute
// force: a short TTL (otpTTL) and a per-code attempt cap (otpMaxAttempts) checked
// inside VerifyEmailOTP. Only the sha-256 of the code is ever stored; the digits
// live only in the email.
const (
// otpTTL bounds how long a freshly mailed code is accepted. Long enough to
// switch to an inbox and back, short enough that a leaked code is useless soon.
otpTTL = 10 * time.Minute
// otpMaxAttempts caps wrong guesses against one code before it locks (429). With
// a 6-digit code (1e6 keyspace) five tries is a ~5e-6 chance of a blind hit; the
// cap is enforced in VerifyEmailOTP (Repo), not here, so the fake and PG agree.
otpMaxAttempts = 5
// otpPurposeOnboard scopes a code to the onboarding email-proof flow. The column
// exists so later flows (e.g. email change) can mint codes that never collide
// with an onboarding code for the same user.
otpPurposeOnboard = "onboard_email"
// otpCodeDigits is the code length; otpCodeBound is its exclusive upper bound, so
// a value in [0, otpCodeBound) zero-pads to exactly otpCodeDigits digits.
otpCodeDigits = 6
otpCodeBound = 1_000_000
)
// OTPMailer delivers a one-time code to an email address. It is a seam, not a
// dependency: the demo ships without SMTP, so a nil Mailer logs the code
// server-side instead of mailing it (a KNOWN-LIMITATION, never a code returned to
// the client). Production wires a real sender.
type OTPMailer interface {
SendOTP(ctx context.Context, email, code string) error
}
// newEmailOTP returns a cryptographically random otpCodeDigits-digit numeric code.
// crypto/rand.Int over a 10^digits bound is uniform with no modulo bias; the value
// is zero-padded so every code is exactly otpCodeDigits long.
func newEmailOTP() (string, error) {
n, err := rand.Int(rand.Reader, big.NewInt(otpCodeBound))
if err != nil {
return "", err
}
return fmt.Sprintf("%0*d", otpCodeDigits, n.Int64()), nil
}
// newOTPID returns an opaque random row id (128 bits, hex) for an email_otps row.
func newOTPID() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
return hex.EncodeToString(b[:]), nil
}
// otpCodeHash maps a code to its storage key (sha-256 hex), reusing the session
// helper so the raw digits are never written to the database.
func otpCodeHash(code string) string { return hashCookie(code) }
// looksLikeEmail is a deliberately small sanity check, not RFC 5322: it rejects the
// obvious garbage (empty, no/multiple '@', '@' at an edge, whitespace, no dot in the
// domain) so a code is never minted against an un-mailable string. Real validation
// is delivery itself — a wrong-but-plausible address simply never yields a code.
func looksLikeEmail(s string) bool {
if len(s) < 3 || len(s) > 254 || strings.ContainsAny(s, " \t\r\n") {
return false
}
at := strings.IndexByte(s, '@')
if at <= 0 || at != strings.LastIndexByte(s, '@') || at == len(s)-1 {
return false
}
domain := s[at+1:]
dot := strings.IndexByte(domain, '.')
return dot > 0 && dot < len(domain)-1
}
// emailOTPStartRequest is the start-onboarding-verification body: the address the
// player wants to prove control of.
type emailOTPStartRequest struct {
Email string `json:"email"`
}
// handleEmailOTPStart mints and delivers a one-time code for the caller's chosen
// email (spec §B2, external app face). The code is bound to the principal's user_id
// and the onboarding purpose; a re-request supersedes the prior code. The response
// NEVER carries the code — it is delivered out of band — only that it was sent and
// when it expires.
func (a *API) handleEmailOTPStart(w http.ResponseWriter, r *http.Request) {
p := principalFromContext(r.Context())
var req emailOTPStartRequest
if err := decodeJSON(w, r, &req); err != nil {
writeError(w, r, err)
return
}
email := strings.TrimSpace(req.Email)
if !looksLikeEmail(email) {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "a valid email is required"))
return
}
code, err := newEmailOTP()
if err != nil {
writeError(w, r, err)
return
}
id, err := newOTPID()
if err != nil {
writeError(w, r, err)
return
}
expiresAt := a.now().Add(otpTTL)
if err := a.Repo.CreateEmailOTP(r.Context(), id, p.UserID, email, otpCodeHash(code), otpPurposeOnboard, expiresAt); err != nil {
writeError(w, r, err)
return
}
if err := a.deliverOTP(r.Context(), email, code); err != nil {
writeError(w, r, err)
return
}
a.audit(r, auditActor(p), "account.email.otp_sent", "")
writeJSON(w, http.StatusAccepted, map[string]any{
"sent": true,
"expires_at": expiresAt.UTC(),
})
}
// emailOTPVerifyRequest is the verify body: the code the player read from the email.
type emailOTPVerifyRequest struct {
Code string `json:"code"`
}
// handleEmailOTPVerify redeems a code for the caller (spec §B2, external app face).
// Outcomes mirror the link-verify shape: an invalid/expired/mismatched code → 400
// invalid_code, a locked code (too many wrong guesses) → 429 otp_locked, and on
// success the user's email is written and email_verified flips true. The verified
// address is echoed so the panel can render it.
func (a *API) handleEmailOTPVerify(w http.ResponseWriter, r *http.Request) {
p := principalFromContext(r.Context())
var req emailOTPVerifyRequest
if err := decodeJSON(w, r, &req); err != nil {
writeError(w, r, err)
return
}
code := strings.TrimSpace(req.Code)
if code == "" {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "code is required"))
return
}
email, err := a.Repo.VerifyEmailOTP(r.Context(), p.UserID, otpPurposeOnboard, otpCodeHash(code), a.now())
switch {
case errors.Is(err, ErrOTPLocked):
writeError(w, r, newError(http.StatusTooManyRequests, "otp_locked",
"too many incorrect attempts; request a new code"))
return
case errors.Is(err, ErrOTPInvalid):
writeError(w, r, newError(http.StatusBadRequest, "invalid_code", "email code is invalid or expired"))
return
case err != nil:
writeError(w, r, err)
return
}
a.audit(r, auditActor(p), "account.email.verified", "")
writeJSON(w, http.StatusOK, map[string]any{"verified": true, "email": email})
}
// deliverOTP hands the code to the configured Mailer, or — when none is wired (the
// demo) — logs it server-side as a KNOWN-LIMITATION. The code is logged ONLY in the
// no-mailer fallback and ONLY to the server log; it is never put in an HTTP response.
func (a *API) deliverOTP(ctx context.Context, email, code string) error {
if a.Mailer == nil {
log.Printf("email-otp: no Mailer configured; code for %s is %s (KNOWN-LIMITATION: demo has no SMTP)", email, code)
return nil
}
return a.Mailer.SendOTP(ctx, email, code)
}
// auditActor picks the most identifying actor string for a principal: the audited
// Access email when present, else the stable user id. A player mid-onboarding may
// not have a verified email yet, so the id keeps the audit row attributable.
func auditActor(p *Principal) string {
if p.Email != "" {
return p.Email
}
return p.UserID
}
+334
View File
@@ -0,0 +1,334 @@
package api
import (
"context"
"errors"
"net/http"
"testing"
"time"
)
// captureMailer is the OTPMailer seam under test: it records the last code so a
// test can read the digits that production would only ever email out of band.
type captureMailer struct {
email, code string
calls int
err error
}
func (m *captureMailer) SendOTP(_ context.Context, email, code string) error {
m.calls++
if m.err != nil {
return m.err
}
m.email, m.code = email, code
return nil
}
// TestEmailOTPVertical walks the whole §B2 email-proof slice across the external
// face: a player asks for a code, the platform delivers it (here, into the test
// mailer), the player types it back, and only then does the user row flip verified.
// It proves the digits never ride the HTTP response and that both halves audit.
func TestEmailOTPVertical(t *testing.T) {
const email = "[email protected]"
user := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
repo := newFakeRepo()
// Seed the staff/user row the verify path flips, keyed (in the fake) by username
// but matched by ID — exactly how PGRepo updates users by id.
repo.staff["player"] = &StaffUser{ID: "u1", Email: "[email protected]"}
mailer := &captureMailer{}
api := newTestAPI(repo, newFakeCluster())
api.External = staticExternal{p: user}
api.Mailer = mailer
eh := api.ExternalHandler()
// 1) start mints + delivers a code. The response says "sent" and when it expires
// — but NEVER the code itself.
w := do(eh, "POST", "/api/v1/account/email/start", `{"email":"`+email+`"}`, nil)
if w.Code != http.StatusAccepted {
t.Fatalf("start: code = %d, want 202 (%s)", w.Code, w.Body.String())
}
b := acctBody(t, w)
if b["sent"] != true {
t.Errorf("start body sent = %v, want true", b["sent"])
}
if _, leaked := b["code"]; leaked {
t.Error("start response must NEVER carry the code")
}
if s, _ := b["expires_at"].(string); s == "" {
t.Error("start must report expires_at")
}
if mailer.calls != 1 || mailer.email != email {
t.Fatalf("mailer not invoked for %s: calls=%d email=%q", email, mailer.calls, mailer.email)
}
code := mailer.code
if len(code) != otpCodeDigits {
t.Fatalf("delivered code %q: len = %d, want %d", code, len(code), otpCodeDigits)
}
// Only the hash is persisted — the plaintext must not be findable in the store.
for _, o := range repo.otps {
if o.codeHash == code {
t.Error("store holds the plaintext code, not its hash")
}
}
// 2) the player submits the code. The email is written and verified flips true.
w = do(eh, "POST", "/api/v1/account/email/verify", `{"code":"`+code+`"}`, nil)
if w.Code != http.StatusOK {
t.Fatalf("verify: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
if vb := acctBody(t, w); vb["verified"] != true || vb["email"] != email {
t.Fatalf("verify body = %v, want verified:true email:%s", vb, email)
}
if su := repo.staff["player"]; !su.EmailVerified || su.Email != email {
t.Fatalf("user row not flipped: verified=%v email=%q", su.EmailVerified, su.Email)
}
// Both halves are audited by the principal's Access email.
if n := len(repo.audits); n != 2 {
t.Fatalf("want 2 audits (otp_sent, verified), got %d: %+v", n, repo.audits)
}
if repo.audits[0].Action != "account.email.otp_sent" || repo.audits[0].Actor != "[email protected]" {
t.Errorf("first audit = %+v, want account.email.otp_sent by [email protected]", repo.audits[0])
}
if repo.audits[1].Action != "account.email.verified" || repo.audits[1].Actor != "[email protected]" {
t.Errorf("second audit = %+v, want account.email.verified by [email protected]", repo.audits[1])
}
// 3) the code is single-use: re-submitting the consumed code now fails.
if w := do(eh, "POST", "/api/v1/account/email/verify", `{"code":"`+code+`"}`, nil); w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_code" {
t.Fatalf("replay of consumed code: code = %d body %s, want 400 invalid_code", w.Code, w.Body.String())
}
}
// TestEmailOTPStartValidation covers the mint-side input gate and the no-mailer
// fallback (the demo path): a malformed address never mints, and a nil Mailer still
// persists a code (logged server-side) so the verify flow stays exercisable.
func TestEmailOTPStartValidation(t *testing.T) {
user := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
mk := func(repo *fakeRepo) http.Handler {
api := newTestAPI(repo, newFakeCluster())
api.External = staticExternal{p: user}
return api.ExternalHandler() // no Mailer wired → demo fallback
}
bad := map[string]string{
"missing email": `{}`,
"empty email": `{"email":""}`,
"whitespace email": `{"email":" "}`,
"no at-sign": `{"email":"notanemail"}`,
"at-sign at edge": `{"email":"@example.net"}`,
"no dot in domain": `{"email":"a@bcd"}`,
"two at-signs": `{"email":"a@[email protected]"}`,
"unknown field": `{"email":"[email protected]","x":1}`,
"trailing at": `{"email":"player@"}`,
}
for name, body := range bad {
t.Run(name, func(t *testing.T) {
repo := newFakeRepo()
w := do(mk(repo), "POST", "/api/v1/account/email/start", body, nil)
if w.Code != http.StatusBadRequest {
t.Fatalf("code = %d, want 400 (%s)", w.Code, w.Body.String())
}
if len(repo.otps) != 0 {
t.Errorf("a rejected start must not mint a code, got %d", len(repo.otps))
}
})
}
t.Run("no mailer still persists a code (demo fallback)", func(t *testing.T) {
repo := newFakeRepo()
w := do(mk(repo), "POST", "/api/v1/account/email/start", `{"email":"[email protected]"}`, nil)
if w.Code != http.StatusAccepted {
t.Fatalf("code = %d, want 202 (%s)", w.Code, w.Body.String())
}
if len(repo.otps) != 1 {
t.Fatalf("want exactly 1 persisted code, got %d", len(repo.otps))
}
})
}
// TestEmailOTPVerifyRejections is the redeem-side failure matrix. The expired and
// locked cases plant rows directly: the test clock is frozen, so an already-expired
// or already-exhausted row is the only way to reach those branches deterministically.
func TestEmailOTPVerifyRejections(t *testing.T) {
user := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
mk := func(repo *fakeRepo) http.Handler {
api := newTestAPI(repo, newFakeCluster())
api.External = staticExternal{p: user}
return api.ExternalHandler()
}
// live seeds an unconsumed onboarding code for u1 with the given hash/expiry.
live := func(repo *fakeRepo, id, codeHash string, expiresAt time.Time, attempts int) {
repo.otps[id] = &fakeEmailOTP{
id: id, userID: "u1", email: "[email protected]", codeHash: codeHash,
purpose: otpPurposeOnboard, attempts: attempts,
expiresAt: expiresAt, createdAt: expiresAt,
}
}
t.Run("empty code -> 400 bad_request", func(t *testing.T) {
w := do(mk(newFakeRepo()), "POST", "/api/v1/account/email/verify", `{"code":""}`, nil)
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "bad_request" {
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
}
})
t.Run("whitespace code -> 400 bad_request", func(t *testing.T) {
w := do(mk(newFakeRepo()), "POST", "/api/v1/account/email/verify", `{"code":" "}`, nil)
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "bad_request" {
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
}
})
t.Run("unknown field -> 400", func(t *testing.T) {
w := do(mk(newFakeRepo()), "POST", "/api/v1/account/email/verify", `{"token":"123456"}`, nil)
if w.Code != http.StatusBadRequest {
t.Fatalf("strict decode must reject unknown field, code = %d", w.Code)
}
})
t.Run("no live code -> 400 invalid_code", func(t *testing.T) {
w := do(mk(newFakeRepo()), "POST", "/api/v1/account/email/verify", `{"code":"000000"}`, nil)
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_code" {
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
}
})
t.Run("expired code -> 400 invalid_code, not consumed", func(t *testing.T) {
repo := newFakeRepo()
// One second before the frozen test clock (time.Unix(1_700_000_000, 0)).
live(repo, "ex", otpCodeHash("424242"), time.Unix(1_699_999_999, 0), 0)
w := do(mk(repo), "POST", "/api/v1/account/email/verify", `{"code":"424242"}`, nil)
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_code" {
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
}
if repo.otps["ex"].consumed {
t.Error("an expired code must not be consumed")
}
})
t.Run("wrong code -> 400 invalid_code, attempt charged, not consumed", func(t *testing.T) {
repo := newFakeRepo()
live(repo, "wr", otpCodeHash("123456"), time.Unix(1_700_000_600, 0), 0)
w := do(mk(repo), "POST", "/api/v1/account/email/verify", `{"code":"654321"}`, nil)
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_code" {
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
}
if repo.otps["wr"].attempts != 1 {
t.Errorf("a wrong guess must cost one attempt, got %d", repo.otps["wr"].attempts)
}
if repo.otps["wr"].consumed {
t.Error("a wrong guess must not consume the code")
}
})
t.Run("exhausted code -> 429 otp_locked", func(t *testing.T) {
repo := newFakeRepo()
// Attempt budget already spent: even the correct code must be refused.
live(repo, "lk", otpCodeHash("123456"), time.Unix(1_700_000_600, 0), otpMaxAttempts)
w := do(mk(repo), "POST", "/api/v1/account/email/verify", `{"code":"123456"}`, nil)
if w.Code != http.StatusTooManyRequests || decodeErr(t, w) != "otp_locked" {
t.Fatalf("code = %d body %s, want 429 otp_locked", w.Code, w.Body.String())
}
})
}
// TestEmailOTPBruteForceLockout drives the lockout end-to-end through the handler:
// wrong guesses are charged one at a time until the budget is spent, after which
// even the correct code is refused with 429 — the brute-force ceiling in action.
func TestEmailOTPBruteForceLockout(t *testing.T) {
user := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
repo := newFakeRepo()
mailer := &captureMailer{}
api := newTestAPI(repo, newFakeCluster())
api.External = staticExternal{p: user}
api.Mailer = mailer
eh := api.ExternalHandler()
if w := do(eh, "POST", "/api/v1/account/email/start", `{"email":"[email protected]"}`, nil); w.Code != http.StatusAccepted {
t.Fatalf("start: code = %d (%s)", w.Code, w.Body.String())
}
good := mailer.code
// Exhaust the budget with wrong guesses; each is a plain invalid_code.
for i := 0; i < otpMaxAttempts; i++ {
w := do(eh, "POST", "/api/v1/account/email/verify", `{"code":"000000"}`, nil)
// "000000" could, with 1-in-a-million odds, equal the real code; guard that.
if good == "000000" {
t.Skip("astronomically unlucky code collision; rerun")
}
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_code" {
t.Fatalf("guess %d: code = %d body %s, want 400 invalid_code", i, w.Code, w.Body.String())
}
}
// Budget spent: the CORRECT code is now locked out, not accepted.
w := do(eh, "POST", "/api/v1/account/email/verify", `{"code":"`+good+`"}`, nil)
if w.Code != http.StatusTooManyRequests || decodeErr(t, w) != "otp_locked" {
t.Fatalf("post-lockout correct code: code = %d body %s, want 429 otp_locked", w.Code, w.Body.String())
}
}
// TestEmailOTPSupersede proves a re-request invalidates the prior code: only the
// newest live code for (user, purpose) can be redeemed, so an intercepted-then-
// reissued code cannot be used after the player asks again.
func TestEmailOTPSupersede(t *testing.T) {
user := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
repo := newFakeRepo()
mailer := &captureMailer{}
api := newTestAPI(repo, newFakeCluster())
api.External = staticExternal{p: user}
api.Mailer = mailer
eh := api.ExternalHandler()
do(eh, "POST", "/api/v1/account/email/start", `{"email":"[email protected]"}`, nil)
first := mailer.code
do(eh, "POST", "/api/v1/account/email/start", `{"email":"[email protected]"}`, nil)
second := mailer.code
if first == second {
t.Skip("rng produced identical codes; rerun")
}
// Only the second code survives.
if w := do(eh, "POST", "/api/v1/account/email/verify", `{"code":"`+first+`"}`, nil); w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_code" {
t.Fatalf("superseded code: code = %d body %s, want 400 invalid_code", w.Code, w.Body.String())
}
if w := do(eh, "POST", "/api/v1/account/email/verify", `{"code":"`+second+`"}`, nil); w.Code != http.StatusOK {
t.Fatalf("current code: code = %d body %s, want 200", w.Code, w.Body.String())
}
}
// TestEmailOTPFaceSeparation enforces that both halves are web-only: they require a
// logged-in principal the internal (service-token) face never carries, so crossing
// the face boundary must 404, not silently work.
func TestEmailOTPFaceSeparation(t *testing.T) {
user := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
api := newTestAPI(newFakeRepo(), newFakeCluster())
api.External = staticExternal{p: user}
ih := api.InternalHandler()
if w := do(ih, "POST", "/api/v1/account/email/start", `{"email":"[email protected]"}`, nil); w.Code != http.StatusNotFound {
t.Errorf("start on internal face: code = %d, want 404", w.Code)
}
if w := do(ih, "POST", "/api/v1/account/email/verify", `{"code":"123456"}`, nil); w.Code != http.StatusNotFound {
t.Errorf("verify on internal face: code = %d, want 404", w.Code)
}
}
// TestEmailOTPMailerError pins the delivery-failure path: a Mailer that errors
// surfaces as a 5xx (the code was persisted but never delivered), and the failure
// is NOT audited as a successful send.
func TestEmailOTPMailerError(t *testing.T) {
user := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
repo := newFakeRepo()
api := newTestAPI(repo, newFakeCluster())
api.External = staticExternal{p: user}
api.Mailer = &captureMailer{err: errors.New("smtp down")}
eh := api.ExternalHandler()
w := do(eh, "POST", "/api/v1/account/email/start", `{"email":"[email protected]"}`, nil)
if w.Code < 500 {
t.Fatalf("mailer error: code = %d, want 5xx (%s)", w.Code, w.Body.String())
}
for _, a := range repo.audits {
if a.Action == "account.email.otp_sent" {
t.Error("a failed delivery must not be audited as otp_sent")
}
}
}
+96 -4
View File
@@ -364,6 +364,98 @@ func (p *PGRepo) Audit(ctx context.Context, e AuditEntry) error {
return err
}
// ---- player email verification (spec §B2 onboarding) ----
// CreateEmailOTP supersedes any prior live code for (user, purpose) and inserts the
// fresh one, in one transaction (spec §B2). The supersede DELETE means a re-request
// invalidates the earlier mail, so only the most recent code can ever verify — a
// player who requested twice cannot be confused into typing the stale digits. Only
// the hash is stored; the digits live only in the email.
func (p *PGRepo) CreateEmailOTP(ctx context.Context, id, userID, email, codeHash, purpose string, expiresAt time.Time) error {
tx, err := p.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback() //nolint:errcheck // no-op after commit
if _, err := tx.ExecContext(ctx,
`DELETE FROM email_otps WHERE user_id = $1 AND purpose = $2 AND consumed_at IS NULL`,
userID, purpose); err != nil {
return fmt.Errorf("supersede prior otp: %w", err)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO email_otps (id, user_id, email, code_hash, purpose, expires_at)
VALUES ($1, $2, $3, $4, $5, $6)`,
id, userID, email, codeHash, purpose, expiresAt); err != nil {
return fmt.Errorf("insert otp: %w", err)
}
return tx.Commit()
}
// VerifyEmailOTP redeems the newest live code for (user, purpose) in one
// transaction (spec §B2). The row is taken FOR UPDATE so a concurrent verify of the
// same code cannot double-spend it. The branch order is deliberate: expiry and the
// attempt cap are checked before the hash compare, so an expired or locked code is
// never silently accepted, and a hash mismatch costs an attempt (UPDATE attempts+1)
// without consuming the code — a typo must not burn a still-valid code. On a match
// the code is consumed and the user row is flipped verified, returning the proven
// address. ErrOTPInvalid / ErrOTPLocked are the only domain errors.
func (p *PGRepo) VerifyEmailOTP(ctx context.Context, userID, purpose, codeHash string, now time.Time) (string, error) {
tx, err := p.db.BeginTx(ctx, nil)
if err != nil {
return "", err
}
defer tx.Rollback() //nolint:errcheck // no-op after commit
var (
id string
email string
storedHash string
attempts int
expiresAt time.Time
)
switch err := tx.QueryRowContext(ctx,
`SELECT id, email, code_hash, attempts, expires_at FROM email_otps
WHERE user_id = $1 AND purpose = $2 AND consumed_at IS NULL
ORDER BY created_at DESC LIMIT 1 FOR UPDATE`,
userID, purpose).Scan(&id, &email, &storedHash, &attempts, &expiresAt); {
case errors.Is(err, sql.ErrNoRows):
return "", ErrOTPInvalid
case err != nil:
return "", err
}
if !expiresAt.After(now) {
return "", ErrOTPInvalid
}
if attempts >= otpMaxAttempts {
return "", ErrOTPLocked
}
if storedHash != codeHash {
if _, err := tx.ExecContext(ctx,
`UPDATE email_otps SET attempts = attempts + 1 WHERE id = $1`, id); err != nil {
return "", fmt.Errorf("record otp attempt: %w", err)
}
if err := tx.Commit(); err != nil {
return "", err
}
return "", ErrOTPInvalid
}
if _, err := tx.ExecContext(ctx,
`UPDATE email_otps SET consumed_at = $2 WHERE id = $1`, id, now); err != nil {
return "", fmt.Errorf("consume otp: %w", err)
}
if _, err := tx.ExecContext(ctx,
`UPDATE users SET email = $2, email_verified = true WHERE id = $1`, userID, email); err != nil {
return "", fmt.Errorf("mark email verified: %w", err)
}
if err := tx.Commit(); err != nil {
return "", err
}
return email, nil
}
// ---- local-password auth (spec §B) ----
// UserByUsername loads a staff login projection by username, or ErrNotFound. A
@@ -372,11 +464,11 @@ func (p *PGRepo) Audit(ctx context.Context, e AuditEntry) error {
// enumerate which usernames carry a password.
func (p *PGRepo) UserByUsername(ctx context.Context, username string) (*StaffUser, error) {
const q = `SELECT id, username, COALESCE(email, ''), role::text,
COALESCE(password_hash, ''), must_change_password
COALESCE(password_hash, ''), must_change_password, email_verified
FROM users WHERE username = $1`
var u StaffUser
switch err := p.db.QueryRowContext(ctx, q, username).Scan(
&u.ID, &u.Username, &u.Email, &u.Role, &u.PasswordHash, &u.MustChangePassword); {
&u.ID, &u.Username, &u.Email, &u.Role, &u.PasswordHash, &u.MustChangePassword, &u.EmailVerified); {
case errors.Is(err, sql.ErrNoRows):
return nil, ErrNotFound
case err != nil:
@@ -406,11 +498,11 @@ func (p *PGRepo) AdminExists(ctx context.Context) (bool, error) {
// session yields a user id, not a username.
func (p *PGRepo) UserByID(ctx context.Context, id string) (*StaffUser, error) {
const q = `SELECT id, username, COALESCE(email, ''), role::text,
COALESCE(password_hash, ''), must_change_password
COALESCE(password_hash, ''), must_change_password, email_verified
FROM users WHERE id = $1`
var u StaffUser
switch err := p.db.QueryRowContext(ctx, q, id).Scan(
&u.ID, &u.Username, &u.Email, &u.Role, &u.PasswordHash, &u.MustChangePassword); {
&u.ID, &u.Username, &u.Email, &u.Role, &u.PasswordHash, &u.MustChangePassword, &u.EmailVerified); {
case errors.Is(err, sql.ErrNoRows):
return nil, ErrNotFound
case err != nil:
+21
View File
@@ -83,6 +83,10 @@ type StaffUser struct {
Role string
PasswordHash string
MustChangePassword bool
// EmailVerified mirrors users.email_verified (spec §B2): the address was proven
// via an email OTP, not merely asserted. Players carry it through onboarding;
// staff rows seeded by break-glass leave it false until a code is redeemed.
EmailVerified bool
}
// SessionedUser is the projection resolved from a live session cookie: the
@@ -172,6 +176,23 @@ type Repo interface {
// Audit appends one audit row.
Audit(ctx context.Context, e AuditEntry) error
// ---- player email verification (spec §B2 onboarding) ----
// CreateEmailOTP persists a freshly minted one-time code for (userID, purpose):
// only its sha-256 (codeHash), never the digits. It supersedes any prior live
// (unconsumed) code for the same (userID, purpose) so a user has at most one
// outstanding code per purpose — a re-request invalidates the earlier mail.
// expiresAt is the API clock + TTL so expiry is driven by one authoritative clock.
CreateEmailOTP(ctx context.Context, id, userID, email, codeHash, purpose string, expiresAt time.Time) error
// VerifyEmailOTP redeems the newest live code for (userID, purpose) against
// codeHash, atomically (spec §B2). No live code, an expired one, or a consumed
// one → ErrOTPInvalid; an exhausted attempt budget → ErrOTPLocked; a hash
// mismatch increments attempts and returns ErrOTPInvalid WITHOUT consuming the
// code (so a typo does not burn it). On a match the code is consumed and the
// user row is flipped to email=<the proven address>, email_verified=true; the
// proven email is returned. now is the API clock so expiry is testable.
VerifyEmailOTP(ctx context.Context, userID, purpose, codeHash string, now time.Time) (email string, err error)
// ---- local-password auth (spec §B) ----
// UserByUsername loads the login projection of a staff account by its unique
@@ -0,0 +1,34 @@
-- Phase B2 player onboarding: verified email via one-time code (OTP).
-- The forced web onboarding flow (link + email-OTP + passkey) needs to prove a
-- player controls an email address before it is bound to their account. A player
-- requests a code, the platform mails it, and the player types it back; only a
-- matching, unexpired, unconsumed code flips users.email_verified true and writes
-- the verified address onto the users row.
-- email_verified records that the address on the users row was proven via OTP, not
-- merely asserted. It defaults false so every existing (and link-only) row reads
-- unverified until a code is redeemed; the verify path is the only writer.
ALTER TABLE users
ADD COLUMN email_verified boolean NOT NULL DEFAULT false;
-- One row per outstanding (and historical) email code. Only the sha-256 of the
-- code is stored, never the digits the player typed, so a database read cannot
-- replay a live code (same principle as sessions.token_hash). A code is single-use:
-- consumed_at is stamped the moment it is redeemed, and attempts caps brute force
-- against the short numeric keyspace independently of expiry.
CREATE TABLE email_otps (
id text PRIMARY KEY, -- opaque row id (random hex)
user_id text NOT NULL REFERENCES users(id),
email text NOT NULL, -- the address this code proves
code_hash text NOT NULL, -- sha-256(code); never the code
purpose text NOT NULL, -- e.g. 'onboard_email'
attempts int NOT NULL DEFAULT 0, -- wrong-guess counter, capped
expires_at timestamptz NOT NULL, -- short TTL set by the API clock
consumed_at timestamptz, -- non-NULL once redeemed (single-use)
created_at timestamptz NOT NULL DEFAULT now()
);
-- The verify path looks up the newest live code for a (user, purpose), so index
-- that lookup. A user has at most one live code per purpose at a time (the mint
-- path supersedes the prior one), keeping this small.
CREATE INDEX email_otps_user_purpose_idx ON email_otps (user_id, purpose);