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

+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
}