feat(auth): migrate console login to passwordless

Replace console password auth with a passwordless surface — the pre-session
login doors plus an identifier-first discovery endpoint — and remove the
password paths.

- Login doors (Public, pre-session): email-OTP, passkey assertion, op.console
  login with in-game approval, and setup-token redeem.
- /api/v1/auth/options: identifier-first discovery reporting which console
  methods an email can use. The single sanctioned existence oracle; methods
  are computed with no role branch, so staff and player accounts in the same
  credential state return byte-identical bodies (staffness invisible by
  construction).
- Remove password auth: drop StaffUser.PasswordHash and the /auth/login,
  /auth/change-password and /users/{id}/reset-password endpoints (and test).
- Data layer: UserByEmail, verified-email uniqueness, setup-token store
  (migration 0012).
- Reconcile docs/openapi.yaml with the served surface; the method/path/face/
  tier parity gate (TestOpenAPIMatchesServedRoutes) passes.
- felis TUI: in-game MC bind, owner/break-glass OP provisioning, version.
- Velocity /felis command suite.

Consolidates the accumulated backend migration work; the frontend (panel/)
is left untouched. Full Go tree green on WSL (go build ./... && go test ./...).
This commit is contained in:
flyemoji committed 2026-07-04 21:47:12 +09:00
1 parent 627883e89a
commit 0c1cc598c1
46 files changed
+5554 -1651

No files matched your search

+74 -59
View File
@@ -104,17 +104,6 @@ type API struct {
// a positive value. Enforced via withinRunningCap on the wake path.
MaxRunningServers int
// MaxConcurrentLogins bounds how many password logins may run their (CPU-costly)
// bcrypt compare at once on the public /auth/login route. bcrypt is deliberately
// expensive and the anti-enumeration path runs a full compare on EVERY request,
// so an unbounded flood of concurrent logins would pin every core; capping the
// simultaneous compares sheds the excess with a cheap 429 instead. Zero — the
// default — disables the cap (same "zero disables" idiom as WakeCooldown /
// MaxRunningServers); cmd/felis wires a positive value. It is a concurrency cap,
// NOT a per-account lockout, so it never fences a break-glass admin out of the
// one account they need. Enforced via loginLimiter in handleLogin.
MaxConcurrentLogins int
// MaxStreamsPerPrincipal caps how many concurrent Server-Sent Event streams
// (console + build-log relays, spec §8) a single principal may hold open at once.
// Each relay blocks for the lifetime of a client's attachment and, under a stalled
@@ -135,9 +124,6 @@ type API struct {
otpCooldownOnce sync.Once
otpCooldown *cooldownLimiter
loginCapOnce sync.Once
loginCap *concurrencyLimiter
streamCapOnce sync.Once
streamCap *streamLimiter
}
@@ -169,16 +155,6 @@ func (a *API) otpLimiter() *cooldownLimiter {
return a.otpCooldown
}
// loginLimiter lazily builds the login bcrypt concurrency cap bound to
// MaxConcurrentLogins. A zero cap yields a disabled limiter that admits every
// caller, so a deployment (or test) that leaves it unset pays nothing.
func (a *API) loginLimiter() *concurrencyLimiter {
a.loginCapOnce.Do(func() {
a.loginCap = newConcurrencyLimiter(a.MaxConcurrentLogins)
})
return a.loginCap
}
// streamGate lazily builds the per-principal SSE stream cap bound to
// MaxStreamsPerPrincipal. A zero cap yields a disabled limiter that admits every
// stream, so a deployment (or test) that leaves it unset pays nothing.
@@ -231,13 +207,9 @@ type apiRoute struct {
// on one route is harmless but redundant — an owner passes both.
Owner bool
// AllowDuringPasswordChange opts a route OUT of the must_change_password
// lockdown (spec §B). The lockdown is default-deny: every authenticated route is
// fenced off for a staff principal that still owes a first-login password change
// EXCEPT the few that let it escape the state — change-password, logout, and the
// self-identity read /me. A new authenticated route is locked down unless it
// sets this, so forgetting the flag fails safe (closed), never open.
AllowDuringPasswordChange bool
// SetupAllowed marks a route as reachable during the setup-lockdown: a session
// whose EmailVerified is false is restricted to these routes only.
SetupAllowed bool
h http.HandlerFunc
}
@@ -283,6 +255,12 @@ func (a *API) internalAPIRoutes() []apiRoute {
// Mojang player (same name, different UUID) always passes.
{Method: "POST", Pattern: "/api/v1/internal/player/reclaim", h: a.handleReclaimUsername},
{Method: "GET", Pattern: "/api/v1/internal/player/blacklist/{mc_uuid}", h: a.handleCheckBlacklist},
// Op-login (passwordless console login): an in-game op requests a login that
// the web owner/admin approves, then redeems for a session. Internal face
// carries the pending queue and the approve action (service-token auth, no
// Principal); the external face carries the start/status/finish the op drives.
{Method: "GET", Pattern: "/api/v1/internal/op-login/pending", h: a.handleOpLoginPending},
{Method: "POST", Pattern: "/api/v1/internal/op-login/{id}/approve", h: a.handleOpLoginApprove},
}
}
@@ -294,14 +272,26 @@ func (a *API) externalAPIRoutes() []apiRoute {
return []apiRoute{
{Method: "GET", Pattern: "/healthz", Public: true, h: a.handleHealthz},
// Local-password auth (spec §B), the op.console login surface. login/logout
// are Public (pre-session: a caller has no principal yet, and logout reads the
// cookie directly so it works even after expiry). change-password requires a
// live session and stays reachable while must_change_password is set
// (AllowDuringPasswordChange) so a forced first-login change can complete.
{Method: "POST", Pattern: "/api/v1/auth/login", Public: true, h: a.handleLogin},
// logout is Public: it reads the cookie directly so it works even after
// expiry. The rest of the auth surface (identifier-first options discovery,
// setup redeem/status, passkey login, email OTP login, op-login) is Public and
// pre-session: a caller has no principal yet.
{Method: "POST", Pattern: "/api/v1/auth/logout", Public: true, h: a.handleLogout},
{Method: "POST", Pattern: "/api/v1/auth/change-password", AllowDuringPasswordChange: true, h: a.handleChangePassword},
// Identifier-first discovery (#71): given an email, report which console methods
// it can use so the SPA prompts for the right authenticator. The deliberate
// counter-slice to the anti-enumeration doors — the ONE sanctioned place existence
// is disclosed — but it never reveals staffness (methods computed with no role
// branch, so a staff and a player address in the same state are indistinguishable).
{Method: "POST", Pattern: "/api/v1/auth/options", Public: true, h: a.handleAuthOptions},
{Method: "POST", Pattern: "/api/v1/auth/setup/redeem", Public: true, h: a.handleSetupRedeem},
{Method: "GET", Pattern: "/api/v1/auth/setup/status", SetupAllowed: true, h: a.handleSetupStatus},
{Method: "POST", Pattern: "/api/v1/auth/passkey/login/begin", Public: true, h: a.handlePasskeyLoginBegin},
{Method: "POST", Pattern: "/api/v1/auth/passkey/login/finish", Public: true, h: a.handlePasskeyLoginFinish},
{Method: "POST", Pattern: "/api/v1/auth/email/start", Public: true, h: a.handleLoginEmailStart},
{Method: "POST", Pattern: "/api/v1/auth/email/verify", Public: true, h: a.handleLoginEmailVerify},
{Method: "POST", Pattern: "/api/v1/auth/op-login/start", Public: true, h: a.handleOpLoginStart},
{Method: "GET", Pattern: "/api/v1/auth/op-login/status/{id}", Public: true, h: a.handleOpLoginStatus},
{Method: "POST", Pattern: "/api/v1/auth/op-login/finish", Public: true, h: a.handleOpLoginFinish},
// Player-console bootstrap (console-tier access model): the account-less
// player's door into console.<root_domain>. Public — like login there is no prior
// principal — and session-minting, but the artifact it consumes is a one-time
@@ -341,10 +331,10 @@ func (a *API) externalAPIRoutes() []apiRoute {
// every authenticated principal may read its OWN identity. is_admin is the
// server-computed Principal.IsAdmin() (Role + admin Access path), so the client
// never re-derives the graded-ZT rule; it remains UX truth, not enforcement.
// /me is exempt from the first-login lockdown so the panel can read its own
// identity (including must_change_password) to render the change-password card.
{Method: "GET", Pattern: "/api/v1/me", AllowDuringPasswordChange: true, h: a.handleMe},
{Method: "GET", Pattern: "/api/v1/me/servers", h: a.handleMyServers},
// /me is reachable during setup-lockdown so the panel can read its own
// identity (including email_verified) to drive the setup flow.
{Method: "GET", Pattern: "/api/v1/me", SetupAllowed: true, h: a.handleMe},
{Method: "GET", Pattern: "/api/v1/me/servers", SetupAllowed: true, h: a.handleMyServers},
// World backups (spec §7, §466). Both are app-tier: GET /backups is scoped
// inside the handler (admin sees all; a user sees only worlds they formerly
// owned), and restore is gated by owner-or-admin PLUS a former-owner match, so
@@ -355,26 +345,26 @@ func (a *API) externalAPIRoutes() []apiRoute {
// pointer handleClaim's 412 emits), /verify consumes the in-game code and binds
// the account. App-tier, not admin — linking your own account is an ordinary
// authenticated operation.
{Method: "POST", Pattern: "/api/v1/account/link/start", h: a.handleLinkStart},
{Method: "POST", Pattern: "/api/v1/account/link/verify", h: a.handleLinkVerify},
{Method: "POST", Pattern: "/api/v1/account/link/start", SetupAllowed: true, h: a.handleLinkStart},
{Method: "POST", Pattern: "/api/v1/account/link/verify", SetupAllowed: true, 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},
{Method: "POST", Pattern: "/api/v1/account/email/start", SetupAllowed: true, h: a.handleEmailOTPStart},
{Method: "POST", Pattern: "/api/v1/account/email/verify", SetupAllowed: true, h: a.handleEmailOTPVerify},
// Passkey enrollment (spec §14 WebAuthn / Phase 6 bind), web side: /register/begin
// mints a credential-creation challenge for the caller, /register/finish verifies
// the authenticator's attestation and binds the passkey, and the credentials
// collection lists and unbinds the caller's OWN passkeys. App-tier like the email
// routes — binding a passkey to your own account is an ordinary authenticated
// operation, scoped entirely to the principal (the body never names a user). This
// is enrollment only; passkey LOGIN/assertion is a deferred slice (see migration
// 0007 and handlers_passkey.go).
{Method: "POST", Pattern: "/api/v1/account/passkey/register/begin", h: a.handlePasskeyRegisterBegin},
{Method: "POST", Pattern: "/api/v1/account/passkey/register/finish", h: a.handlePasskeyRegisterFinish},
{Method: "GET", Pattern: "/api/v1/account/passkey/credentials", h: a.handlePasskeyList},
{Method: "DELETE", Pattern: "/api/v1/account/passkey/credentials/{id}", h: a.handlePasskeyDelete},
// is the ENROLLMENT side; the passkey LOGIN/assertion door is the Public,
// pre-session /api/v1/auth/passkey/login/{begin,finish} pair above.
{Method: "POST", Pattern: "/api/v1/account/passkey/register/begin", SetupAllowed: true, h: a.handlePasskeyRegisterBegin},
{Method: "POST", Pattern: "/api/v1/account/passkey/register/finish", SetupAllowed: true, h: a.handlePasskeyRegisterFinish},
{Method: "GET", Pattern: "/api/v1/account/passkey/credentials", SetupAllowed: true, h: a.handlePasskeyList},
{Method: "DELETE", Pattern: "/api/v1/account/passkey/credentials/{id}", SetupAllowed: true, h: a.handlePasskeyDelete},
// 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
@@ -431,7 +421,6 @@ func (a *API) externalAPIRoutes() []apiRoute {
{Method: "PATCH", Pattern: "/api/v1/users/{id}", Owner: true, h: a.handlePatchUser},
{Method: "DELETE", Pattern: "/api/v1/users/{id}", Owner: true, h: a.handleDeleteUser},
{Method: "POST", Pattern: "/api/v1/users/{id}/disable", Owner: true, h: a.handleDisableUser},
{Method: "POST", Pattern: "/api/v1/users/{id}/reset-password", Owner: true, h: a.handleResetPassword},
{Method: "GET", Pattern: "/api/v1/users/{id}/quotas", Owner: true, h: a.handleGetQuotas},
{Method: "PUT", Pattern: "/api/v1/users/{id}/quotas", Owner: true, h: a.handleSetQuotas},
{Method: "GET", Pattern: "/api/v1/users/{id}/sessions", Owner: true, h: a.handleListUserSessions},
@@ -477,15 +466,22 @@ func (a *API) buildFace(routes []apiRoute, guard func(http.Handler) http.Handler
if rt.Admin {
h = a.adminOnly(rt.h)
}
// Default-deny first-login lockdown (spec §B): wrap every authenticated route
// unless it explicitly opts out. The wrapper is nil-principal safe, so it is
// inert on the internal face (service-token callers carry no Principal).
if !rt.AllowDuringPasswordChange {
h = a.lockdownDuringPasswordChange(h)
// Default-deny setup-lockdown: wrap every authenticated route unless it
// explicitly opts out. The wrapper is nil-principal safe, so it is inert on
// the internal face (service-token callers carry no Principal).
if !rt.SetupAllowed {
h = a.requireEmailVerified(h)
}
auth.HandleFunc(pattern, h)
}
mux.Handle("/api/v1/", guard(auth))
guarded := guard(auth)
mux.Handle("/api/v1/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, pattern := auth.Handler(r); pattern == "" {
http.NotFound(w, r)
return
}
guarded.ServeHTTP(w, r)
}))
return a.baseChain(mux)
}
@@ -494,6 +490,25 @@ func (a *API) baseChain(h http.Handler) http.Handler {
return withRequestID(withRecover(h))
}
// requireEmailVerified fences an authenticated route behind the setup-lockdown:
// a session whose EmailVerified is false (a freshly-onboarded principal that has
// not yet proved control of its email) is restricted to SetupAllowed routes only.
// The wrapper is nil-principal safe, so it is inert on the internal face
// (service-token callers carry no Principal) and on the external face's admin
// Zero-Trust paths (those carry an IsAdmin/IsOwner principal that has already
// passed email verification at account creation).
func (a *API) requireEmailVerified(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
p := principalFromContext(r.Context())
if p != nil && p.ViaSession && !p.EmailVerified {
writeError(w, r, newError(http.StatusForbidden, "setup_required",
"email verification is required before this action is available"))
return
}
h(w, r)
}
}
// ---- request context plumbing ----
type ctxKey int