package api import ( "bytes" "encoding/json" "errors" "log" "net/http" "strings" "time" ) // Reauth (step-up) guards the changes that plant or remove a lasting way into an // account: adding or removing a passkey and changing the email. Holding the // session is not enough for them once the account has a factor of its own; the // holder must have proven one within reauthWindow. Otherwise a stolen cookie // (XSS, a shared machine) could register the thief's passkey and keep the // account long after the session ends, and for staff that passkey would skip // op-login's in-game approval for good. // // What proves a factor, and so marks the session (sessions.reauth_at): // // - signing in by passkey, by email code, through op-login or with the setup // token (startSession with provenSignIn); // - a passkey assertion or an email code on the reauth endpoints below; // - verifying an email address by code (the address is proven that moment, // and reaching that step already passed this gate when the account had a // factor to protect). // // A bind-code sign-in proves only the in-game identity and marks nothing: whoever // controls the Minecraft account must still show the account's passkey or mailbox // before touching them. // // Staff reauth with a passkey or by signing in again through op-login. An email // code alone is not a staff factor, because signing in as staff by email also // takes in-game approval. const ( // reauthWindow is how long a proven factor lets the session make guarded // changes. Long enough to finish a passkey ceremony or an email change. reauthWindow = 5 * time.Minute otpPurposeReauth = "reauth" passkeyPurposeReauth = "passkey_reauth" reauthFactorPasskey = "passkey" reauthFactorEmail = "email" // reauthFactorSignIn: sign out and back in through a proving door. reauthFactorSignIn = "sign_in" ) // reauthState is where the caller stands with the guarded changes. type reauthState struct { // Needed: a guarded change would be refused until the caller reauths. Needed bool `json:"needed"` // Until is when the current proof stops counting; absent when there is none // or the account has nothing to guard. Until *time.Time `json:"until,omitempty"` // Factors are the ways this caller can reauth, best first. Factors []string `json:"factors"` } func (a *API) reauthState(r *http.Request, p *Principal) (reauthState, error) { st := reauthState{Factors: []string{}} if !p.ViaSession { // A Cloudflare Access caller is authenticated by the proxy on every // request and has no session here to mark. return st, nil } hasPasskey, err := a.userHasPasskey(r.Context(), p.UserID) if err != nil { return st, err } if hasPasskey { st.Factors = append(st.Factors, reauthFactorPasskey) } // A verified email is a way in only while a relay can mail it a code: with // none, the email and op-login doors answer 503 mail_unavailable, so it is // neither a factor to offer nor a door to guard. emailWayIn := p.EmailVerified && a.Mailer != nil if emailWayIn { if staffRole(p.Role) { st.Factors = append(st.Factors, reauthFactorSignIn) } else { st.Factors = append(st.Factors, reauthFactorEmail) } } if !hasPasskey && !emailWayIn { // Nothing to protect yet: the session is the account's only way in. return st, nil } if until := p.ReauthAt.Add(reauthWindow); !p.ReauthAt.IsZero() && a.now().Before(until) { until = until.UTC() st.Until = &until return st, nil } st.Needed = true return st, nil } // requireReauth lets a guarded change through, or answers 403 reauth_required // and returns false. func (a *API) requireReauth(w http.ResponseWriter, r *http.Request, p *Principal) bool { st, err := a.reauthState(r, p) if err != nil { writeError(w, r, err) return false } if st.Needed { writeError(w, r, newError(http.StatusForbidden, "reauth_required", "confirm it's you first: this change needs your passkey or email code from the last few minutes")) return false } return true } // markReauth records the proof on the caller's session and answers with the new // window. func (a *API) markReauth(w http.ResponseWriter, r *http.Request, p *Principal, factor string) { now := a.now() if err := a.Repo.MarkSessionReauth(r.Context(), currentSessionHash(r), now); err != nil { writeError(w, r, err) return } a.audit(r, "account.reauth", factor) writeJSON(w, http.StatusOK, map[string]any{"ok": true, "until": now.Add(reauthWindow).UTC()}) } // markReauthQuietly records a proof that happened as part of another change // (a passkey registration, an email verification). The change already went // through, so a failure here is logged and the next guarded change just asks. func (a *API) markReauthQuietly(r *http.Request) { hash := currentSessionHash(r) if hash == "" { return } if err := a.Repo.MarkSessionReauth(r.Context(), hash, a.now()); err != nil { log.Printf("auth: could not record reauth on the session (request_id=%s): %v", requestIDFromContext(r.Context()), err) } } // handleReauthStatus reports whether a guarded change needs a reauth first and // which factors can provide it, so the panel can ask before starting one. func (a *API) handleReauthStatus(w http.ResponseWriter, r *http.Request) { st, err := a.reauthState(r, principalFromContext(r.Context())) if err != nil { writeError(w, r, err) return } writeJSON(w, http.StatusOK, st) } // requireReauthSession refuses the reauth endpoints to a caller with no session // to mark. func requireReauthSession(w http.ResponseWriter, r *http.Request, p *Principal) bool { if !p.ViaSession || currentSessionHash(r) == "" { writeError(w, r, newError(http.StatusBadRequest, "no_session", "only a signed-in browser session can confirm it's you")) return false } return true } func (a *API) handleReauthPasskeyBegin(w http.ResponseWriter, r *http.Request) { p := principalFromContext(r.Context()) if a.Passkey == nil { writeError(w, r, errPasskeyUnavailable) return } if !requireReauthSession(w, r, p) { return } a.beginStepUpPasskey(w, r, p, passkeyPurposeReauth, "no passkey enrolled; confirm with an email code instead") } func (a *API) handleReauthPasskeyFinish(w http.ResponseWriter, r *http.Request) { p := principalFromContext(r.Context()) if a.Passkey == nil { writeError(w, r, errPasskeyUnavailable) return } var req stepUpPasskeyFinishRequest if err := decodeJSON(w, r, &req); err != nil { writeError(w, r, err) return } if len(req.Assertion) == 0 { writeError(w, r, newError(http.StatusBadRequest, "bad_request", "assertion is required")) return } if !requireReauthSession(w, r, p) { return } if !a.finishStepUpPasskey(w, r, p, passkeyPurposeReauth, "reauth_passkey", req.Assertion) { return } a.markReauth(w, r, p, reauthFactorPasskey) } // requireEmailReauth admits a player with a verified address to the email-code // reauth; staff confirm with a passkey or by signing in again. func requireEmailReauth(w http.ResponseWriter, r *http.Request, p *Principal) bool { if staffRole(p.Role) { writeError(w, r, newError(http.StatusForbidden, "staff_reauth", "operators confirm with a passkey or by signing in again")) return false } if !p.EmailVerified || p.Email == "" { writeError(w, r, newError(http.StatusConflict, "no_step_up_factor", "there is no verified email on this account to send a code to")) return false } return true } func (a *API) handleReauthEmailStart(w http.ResponseWriter, r *http.Request) { p := principalFromContext(r.Context()) if !requireReauthSession(w, r, p) || !requireEmailReauth(w, r, p) { return } a.startStepUpOTP(w, r, p, otpPurposeReauth, "reauth:", "account.reauth.otp_sent") } func (a *API) handleReauthEmailVerify(w http.ResponseWriter, r *http.Request) { p := principalFromContext(r.Context()) var req stepUpOTPVerifyRequest 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 } if !requireReauthSession(w, r, p) || !requireEmailReauth(w, r, p) { return } if !a.verifyStepUpOTP(w, r, p, otpPurposeReauth, "reauth_email", code) { return } a.markReauth(w, r, p, reauthFactorEmail) } // ---- step-up ceremonies shared by reauth and the migration confirm ---- // stepUpPasskeyFinishRequest is the assertion the browser produced, captured as // raw bytes so the exact response reaches the verifier without re-encoding. type stepUpPasskeyFinishRequest struct { Assertion json.RawMessage `json:"assertion"` } // stepUpOTPVerifyRequest is the code from the step-up email. type stepUpOTPVerifyRequest struct { Code string `json:"code"` } // stepUpPasskeyUser builds the PasskeyUser the assertion ceremony needs for the // already signed-in caller (contrast the login door, which resolves it from a // typed email). The credential set must be identical between begin and finish. func stepUpPasskeyUser(p *Principal, creds []PasskeyCredential) PasskeyUser { name := p.Email if name == "" { name = p.UserID } return PasskeyUser{ID: p.UserID, Name: name, DisplayName: name, Credentials: creds} } // beginStepUpPasskey starts an assertion over the caller's own passkeys, its // challenge stashed under purpose, and writes the options (go-webauthn's // {"publicKey": {...}} document). The caller has checked a.Passkey. func (a *API) beginStepUpPasskey(w http.ResponseWriter, r *http.Request, p *Principal, purpose, noPasskey string) { creds, err := a.Repo.PasskeyCredentialsForUser(r.Context(), p.UserID) if err != nil { writeError(w, r, err) return } if len(creds) == 0 { writeError(w, r, newError(http.StatusBadRequest, "no_passkey", "%s", noPasskey)) return } options, sessionData, err := a.Passkey.BeginLogin(stepUpPasskeyUser(p, creds)) if err != nil { writeError(w, r, newError(http.StatusBadRequest, "passkey_login_failed", "could not start passkey confirmation")) return } id, err := newPasskeyID() if err != nil { writeError(w, r, err) return } expiresAt := a.now().Add(passkeyChallengeTTL) if err := a.Repo.CreatePasskeyChallenge(r.Context(), id, p.UserID, purpose, sessionData, expiresAt); err != nil { writeError(w, r, err) return } writeJSON(w, http.StatusOK, options) } // finishStepUpPasskey consumes the purpose's stashed challenge and verifies the // assertion against the caller's passkeys. It reports whether the caller passed; // on false the error is written. door names the failure in metrics and audit. // The caller has checked a.Passkey and that the assertion is present. func (a *API) finishStepUpPasskey(w http.ResponseWriter, r *http.Request, p *Principal, purpose, door string, assertion json.RawMessage) bool { invalid := func() bool { writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid", "passkey confirmation could not be completed; begin again")) return false } sessionData, err := a.Repo.ConsumePasskeyChallengeByUser(r.Context(), p.UserID, purpose, a.now()) if err != nil { if errors.Is(err, ErrPasskeyChallengeInvalid) { a.authFailure(r, door, "challenge_invalid", nil) return invalid() } writeError(w, r, err) return false } creds, err := a.Repo.PasskeyCredentialsForUser(r.Context(), p.UserID) if err != nil { writeError(w, r, err) return false } va, err := a.Passkey.FinishLogin(stepUpPasskeyUser(p, creds), sessionData, bytes.NewReader(assertion)) if err != nil { a.authFailure(r, door, "bad_assertion", nil) return invalid() } // Same UV and clone policy as the login door (applyAssertion): an unverified // user or a rolled-back counter fails closed with the opaque envelope, so a // step-up never accepts an authenticator that login refuses. A clean assertion // advances the stored sign-count, keeping the clone signal meaningful. if err := a.applyAssertion(r.Context(), va, creds); err != nil { if a.passkeyAssertionRejected(r, door, nil, va.CredentialID, err) { return invalid() } writeError(w, r, err) return false } return true } // startStepUpOTP mails a fresh code under purpose to the caller's (verified) // address and answers 202. keyPrefix namespaces the per-mailbox resend cooldown // so the step-up doors never perturb each other's throttle. func (a *API) startStepUpOTP(w http.ResponseWriter, r *http.Request, p *Principal, purpose, keyPrefix, auditAction string) { if err := a.checkMailBudget(); err != nil { writeError(w, r, err) return } if until, err := a.Repo.OTPLockedUntil(r.Context(), p.UserID, purpose, a.now()); err != nil { writeError(w, r, err) return } else if !until.IsZero() { writeOTPAccountLocked(w, r, until, a.now()) return } emailKey := keyPrefix + strings.ToLower(p.Email) lim := a.otpLimiter() emailAt, ok := lim.reserve(emailKey, otpResendCooldown) if !ok { 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(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, p.Email, otpCodeHash(code), purpose, expiresAt); err != nil { writeError(w, r, err) return } if err := a.deliverOTP(r.Context(), p.Email, code); err != nil { writeError(w, r, err) return } committed = true a.audit(r, auditAction, "") writeJSON(w, http.StatusAccepted, map[string]any{"sent": true, "expires_at": expiresAt.UTC()}) } // verifyStepUpOTP redeems a step-up code. The lifecycle is the login door's (no // identity side effect): the address is already proven. It reports whether the // code matched; on false the error is written. func (a *API) verifyStepUpOTP(w http.ResponseWriter, r *http.Request, p *Principal, purpose, door, code string) bool { var lock *OTPAccountLockedError err := a.Repo.ConsumeLoginEmailOTP(r.Context(), p.UserID, purpose, otpCodeHash(code), a.now()) if isOTPRefusal(err) { a.authFailure(r, door, otpFailureReason(err), nil) } switch { case errors.As(err, &lock): a.noteOTPLock(r, err, p.UserID, purpose) writeOTPAccountLocked(w, r, lock.Until, a.now()) return false case errors.Is(err, ErrOTPLocked): writeError(w, r, newError(http.StatusTooManyRequests, "otp_locked", "too many incorrect attempts; request a new code")) return false case errors.Is(err, ErrOTPInvalid): writeError(w, r, newError(http.StatusBadRequest, "invalid_code", "email code is invalid or expired")) return false case err != nil: writeError(w, r, err) return false } return true }