package api import ( "context" "crypto/rand" "encoding/hex" "errors" "fmt" "log" "math/big" "net/http" "strings" "time" "felis.lolicon.best/internal/metrics" ) // 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 // otpResendCooldown is the minimum spacing between OTP sends. Without it, // handleEmailOTPStart is an email-bomb primitive: an authenticated caller could // drive unbounded mail to any address they type. The cooldown is enforced on two // keys (principal and recipient) so neither one account fanning out across many // addresses, nor many accounts converging on one address, can flood a mailbox. otpResendCooldown = 60 * time.Second // otpFailureBudget caps wrong codes per (user, purpose) inside otpFailureWindow, // across every code minted in it. The per-code cap alone resets on each resend, // and the public login door can mint a code a minute, so without this an // attacker gets ~7,200 guesses a day at one account. Ten a day puts a blind hit // at 1e-5 per day; a person who mistypes that often can wait out the window or // use a passkey. otpFailureBudget = 10 otpFailureWindow = 24 * time.Hour // otpLiveLoginCodes is how many codes a pre-session login door (email login, // op-login) keeps redeemable per (account, purpose). Anyone who knows an address // can start a login for it, so a start never cancels the codes already mailed: // with one mail per otpResendCooldown, every code stays good for at least // otpLiveLoginCodes cooldowns (or its TTL), and a stranger's starts only add // codes to the owner's inbox. A wrong guess is compared with every live code, so // the daily blind-hit chance rises to otpFailureBudget*otpLiveLoginCodes/1e6 // (3e-5). Enforced in the Repo so the fake and PG agree. otpLiveLoginCodes = 3 ) // OTPMailer delivers a one-time code to an email address. felis api wires the // [smtp] relay (internal/mail); with none configured it stays nil and every door // that mails a code answers 503 mail_unavailable before minting one. 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 } // No relay (or a spent budget) is said before asking for a re-verification // the player could not then use. if err := a.checkMailBudget(); err != nil { writeError(w, r, err) return } // Gate the start: the verify only redeems a code minted here. if !a.requireReauth(w, r, p) { return } // Atomically reserve the cooldown on both the caller and the recipient BEFORE // minting, so a burst of truly concurrent starts yields exactly one winner. Here // the throttle is the sole defense and each admitted send is a real, non-idempotent // email, so an allowed→record peek would let N goroutines slip past together and // bomb a mailbox. The recipient key is lower-cased so case variants of one address // can't sidestep the per-mailbox cap. If any later step fails the deferred rollback // frees both windows, so a failed mint or delivery never consumes the cooldown — // the same property the old record-after-send gave, now race-free. // A spent wrong-code budget locks the door; mailing another code into it // would only spend a send. if until, err := a.Repo.OTPLockedUntil(r.Context(), p.UserID, otpPurposeOnboard, a.now()); err != nil { writeError(w, r, err) return } else if !until.IsZero() { writeOTPAccountLocked(w, r, until, a.now()) return } userKey, emailKey := "user:"+p.UserID, "email:"+strings.ToLower(email) lim := a.otpLimiter() userAt, ok := lim.reserve(userKey, otpResendCooldown) if !ok { writeError(w, r, newError(http.StatusTooManyRequests, "otp_resend_cooldown", "a code was sent recently; wait a moment before requesting another")) return } emailAt, ok := lim.reserve(emailKey, otpResendCooldown) if !ok { lim.release(userKey, userAt) writeError(w, r, newError(http.StatusTooManyRequests, "otp_resend_cooldown", "a code was sent recently; wait a moment before requesting another")) return } committed := false defer func() { if !committed { lim.release(userKey, userAt) lim.release(emailKey, emailAt) } }() 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 } // The send succeeded: keep both reservations (the deferred rollback becomes a // no-op) so the cooldown windows stand. committed = true a.audit(r, "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, an address // another account already proved → 409 email_taken (the code stays live — the // address, not the code, is the problem), 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()) if isOTPRefusal(err) { a.authFailure(r, "onboard_email", otpFailureReason(err), nil) } var lock *OTPAccountLockedError switch { case errors.As(err, &lock): a.noteOTPLock(r, err, p.UserID, otpPurposeOnboard) writeOTPAccountLocked(w, r, lock.Until, a.now()) return 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 errors.Is(err, ErrEmailTaken): writeError(w, r, newError(http.StatusConflict, "email_taken", "that email is already verified on another account; sign in with it or use another address")) return case err != nil: writeError(w, r, err) return } a.audit(r, "account.email.verified", "") // Proving the address is an email reauth. a.markReauthQuietly(r) // Replacing a verified address moves where sign-in codes go, so a session // opened through the old one ends, and the old mailbox hears about it. A // first verification retires nothing. if p.EmailVerified && !strings.EqualFold(p.Email, email) { a.revokeOtherSessionsAfter(r, "email change") a.notifyEmailChanged(r, p.Email, email) } writeJSON(w, http.StatusOK, map[string]any{"verified": true, "email": email}) } // deliverOTP hands the code to the configured Mailer. The code goes nowhere // else: never into a response and never into a log, since anyone who can read // the API's logs could otherwise sign in as any player. The doors refuse a // relay-less install before minting (checkMailBudget); the nil check here only // keeps a future caller that skips that check from minting a code no one gets. // // Every real send spends one token of the install-wide mail budget (mailGate); // a spent budget is a 429 mail_rate_limited and nothing reaches the relay. func (a *API) deliverOTP(ctx context.Context, email, code string) error { if a.Mailer == nil { return errMailUnavailable() } if ok, wait := a.mailGate().take(mailGateKey); !ok { metrics.MailTotal.WithLabelValues("otp", "throttled").Inc() log.Printf("api: OTP mail refused by the install-wide mail budget (request_id=%s)", requestIDFromContext(ctx)) return errMailRateLimited(wait) } if err := a.Mailer.SendOTP(ctx, email, code); err != nil { metrics.MailTotal.WithLabelValues("otp", "failed").Inc() // Mapped here rather than at each of the four call sites, so every door that // mails a code answers the same way. A relay refusal is neither the caller's // fault nor a bug in Felis, and a bare 500 says neither — it reads as "the // panel is broken" and sends the operator hunting through handler code // instead of their [smtp] block. // // The relay's own text stays in the log: it can name the SMTP account and the // sending identity ("smtp: auth as ops@example.net: 535 …"), and these routes // are reachable by any signed-in player. log.Printf("api: OTP delivery failed (request_id=%s): %v", requestIDFromContext(ctx), err) return newError(http.StatusBadGateway, "mail_undeliverable", "the mail relay refused this message; ask the server operator to check the SMTP settings") } metrics.MailTotal.WithLabelValues("otp", "sent").Inc() return nil } // setEmailRequest is the record-email body: the address to bind to the caller's // account WITHOUT an OTP round-trip. type setEmailRequest struct { Email string `json:"email"` } // handleSetEmail records the caller's email without verifying it (SetupAllowed). The // setup bootstrap has no SMTP, so the Owner cannot receive an emailed code; the // address is stored unverified and a later Settings/SMTP flow proves control of it. // This is the setup wizard's Step-1 write. The OTP start/verify pair above is left // intact for the Account page and for post-SMTP verification — this door deliberately // does NOT touch email_verified. func (a *API) handleSetEmail(w http.ResponseWriter, r *http.Request) { p := principalFromContext(r.Context()) if err := requireJSONContentType(r); err != nil { writeError(w, r, err) return } var req setEmailRequest 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 } // Recording an address unverifies the current one, which would strip the // account's email factor and with it the reauth that guards adding a passkey. if !a.requireReauth(w, r, p) { return } if err := a.Repo.SetUserEmail(r.Context(), p.UserID, email); err != nil { writeError(w, r, err) return } a.audit(r, "account.email.set", "") writeJSON(w, http.StatusOK, map[string]any{"email": email}) }