Unverified Commit dbe34a17 authored by Minseong Choi's avatar Minseong Choi 💬
Browse files

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.
parent 2d0bbb0c
Loading
Loading
Loading
Loading
+87 −0
Changes for docs/openapi.yaml: 87 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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 −0
Changes for internal/api/api.go: 13 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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 −0
Changes for internal/api/api_test.go: 69 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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 −0
Changes for internal/api/errors.go: 11 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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 −0
Changes for internal/api/handlers_email_otp.go: 202 added lines, 0 removed lines.
Original line number Diff line number Diff line
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
}
Loading