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

+603 -109
View File
@@ -879,6 +879,94 @@ paths:
'401':
$ref: '#/components/responses/Unauthorized'
/api/v1/internal/op-login/pending:
get:
tags: [account-internal]
operationId: opLoginPending
summary: List live pending op.console login requests, oldest first (spec §B).
description: >
Internal-only. Velocity polls it and pushes waiting requests to online admins,
who approve one with /felis web op approve <id>. No pending request is secret
to the operator crew.
x-felis-face: [internal]
x-felis-tier: service
security: [{ serviceToken: [] }]
responses:
'200':
description: The pending requests awaiting an in-game vouch.
content:
application/json:
schema:
type: object
required: [pending]
properties:
pending:
type: array
items:
type: object
required: [request_id, username, email, created_at]
properties:
request_id: { type: string }
username: { type: string }
email: { type: string }
created_at: { type: string, format: date-time }
'401':
$ref: '#/components/responses/Unauthorized'
/api/v1/internal/op-login/{id}/approve:
post:
tags: [account-internal]
operationId: opLoginApprove
summary: Record an in-game admin's vouch for a pending op.console login (spec §B).
description: >
Internal-only second factor: velocity submits the online-mode UUID of the
in-game admin running /felis web op approve. The API resolves it to a linked
role=admin account (else 403 not_admin) and flips the request approved. A
missing or no-longer-pending request is 404. Self-approval is allowed — an
online staff member vouching as their own admin identity is a genuine second
factor distinct from the mailbox.
x-felis-face: [internal]
x-felis-tier: service
security: [{ serviceToken: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [approver_uuid]
properties:
approver_uuid: { type: string, format: uuid }
responses:
'200':
description: The vouch was recorded; the request is now approved.
content:
application/json:
schema:
type: object
required: [approved]
properties:
approved: { type: boolean, const: true }
'400':
description: approver_uuid is required (bad_request).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'401':
$ref: '#/components/responses/Unauthorized'
'403':
description: The approver is not a linked administrator (not_admin).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'404':
description: No pending operator login with that id (op_login_not_found).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
# ----------------------------------------------------- external: servers ---
/api/v1/servers/{name}/wake:
post:
@@ -1431,18 +1519,22 @@ paths:
$ref: '#/components/responses/NotFound'
# -------------------------------------------------- external: local auth ---
/api/v1/auth/login:
/api/v1/auth/options:
post:
tags: [auth]
operationId: login
summary: Log in with a local username + password (op.console).
operationId: authOptions
summary: Identifier-first login discovery — which methods can this email use (spec §B, #71).
description: >-
Verifies a username+password against the users row and, on success, mints
a host-only session cookie (spec §B). Mounted Public — there is no prior
principal — but local auth must be enabled (local_auth_enabled), so a
deployment fronted entirely by Zero Trust never accepts a local password.
Every failure returns the same vague invalid_credentials after a uniform
bcrypt compare, so usernames cannot be enumerated by response or timing.
Public, pre-session discovery for the SPA's identifier-first form: given a typed
email, report which console login methods the account can use (passkey and/or
email-OTP) so the UI prompts for the right authenticator. This is the deliberate
counter-slice to the anti-enumeration login doors — the ONE sanctioned place
account existence is disclosed, so an unknown address returns an empty methods
array. It never reveals staffness: methods are computed identically for every
resolved account (no role branch), so a staff and a player address in the same
credential state return byte-identical bodies. passkey is offered only when a
verifier is wired. Sends no mail and mutates nothing; not rate-limited at the app
layer (volumetric abuse is bounded at the edge). Gated on local_auth_enabled.
x-felis-face: [external]
x-felis-tier: public
security: []
@@ -1452,37 +1544,521 @@ paths:
application/json:
schema:
type: object
required: [username, password]
required: [email]
properties:
username: { type: string }
password: { type: string, format: password }
email: { type: string, format: email }
responses:
'200':
description: Session established; the cookie is set on the response.
description: >-
The login methods available for the address, in a deterministic order
(passkey before email_otp). An empty array means no verified account.
content:
application/json:
schema:
type: object
required: [user_id, role, must_change_password]
required: [methods]
properties:
user_id: { type: string }
role:
type: string
enum: [user, admin]
must_change_password:
type: boolean
description: >-
True when this account still owes its first-login password
change; the panel routes straight to the change-password card.
methods:
type: array
items: { type: string, enum: [passkey, email_otp] }
'400':
$ref: '#/components/responses/BadRequest'
'401':
description: Invalid username or password (vague by design).
description: A valid email is required (bad_request).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'403':
description: Local password login is disabled on this deployment.
description: Local session login is disabled on this deployment (local_auth_disabled).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'415':
description: Request body was not application/json.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
/api/v1/auth/passkey/login/begin:
post:
tags: [auth]
operationId: passkeyLoginBegin
summary: Begin a passwordless passkey (WebAuthn) login (spec §14, §B).
description: >-
First leg of the public, pre-session passkey assertion door: the caller
supplies the email that selects the account and, on success, receives the raw
PublicKeyCredentialRequestOptions to hand to navigator.credentials.get(). The
matching challenge is stashed server-side and redeemed by finish. Mounted
Public (no prior principal) and gated on local_auth_enabled. An unknown
address and a known account with no enrolled passkey both return the SAME 400
no_passkey, so the door is not an existence oracle; a per-recipient cooldown
(shared shape with the email-OTP and op-login doors) throttles probing.
x-felis-face: [external]
x-felis-tier: public
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [email]
properties:
email: { type: string, format: email }
responses:
'200':
description: >-
The WebAuthn assertion options (PublicKeyCredentialRequestOptions), passed
through verbatim from the authenticator library for the browser to consume.
The body is the WebAuthn standard shape and is not modelled here.
content:
application/json:
schema: { type: object, additionalProperties: true }
'400':
description: >-
Invalid email (bad_request); or no passkey is enrolled for the account, or
the address is unknown — indistinguishable by design (no_passkey); or the
authenticator library could not start the ceremony (passkey_login_failed).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'403':
description: Local session login is disabled on this deployment (local_auth_disabled).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'415':
description: Request body was not application/json.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'429':
description: A passkey login for this recipient was started too recently (otp_resend_cooldown).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'503':
description: No passkey verifier is wired on this deployment (passkey_unavailable).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
/api/v1/auth/passkey/login/finish:
post:
tags: [auth]
operationId: passkeyLoginFinish
summary: Complete a passkey (WebAuthn) login and mint a session (spec §14, §B).
description: >-
Second leg of the public passkey door: the caller returns the email (to
re-select the account) and the raw navigator.credentials.get() assertion. The
stashed login challenge is consumed atomically and the assertion is verified
against it; on success a host-only felis_session cookie is minted. Both players
and staff may log in this way — a passkey is a two-factor authenticator
(possession + user verification), strong enough to stand alone without the
in-game approval op-login requires. Every failure mode (unknown address, no
live challenge, expired challenge, bad assertion) collapses into one uniform
passkey_login_invalid, so the door reveals nothing.
x-felis-face: [external]
x-felis-tier: public
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [email, assertion]
properties:
email: { type: string, format: email }
assertion:
type: object
additionalProperties: true
description: >-
The raw PublicKeyCredential from navigator.credentials.get(),
passed to the verifier verbatim (WebAuthn standard shape).
responses:
'200':
description: Assertion verified; a host-only session cookie is set on the response.
content:
application/json:
schema:
type: object
required: [user_id, role]
properties:
user_id: { type: string }
role: { type: string, enum: [user, admin] }
'400':
description: >-
Invalid email or missing assertion (bad_request); or the login could not be
completed — unknown address, no live or expired challenge, or a failed
assertion, all uniform (passkey_login_invalid).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'403':
description: Local session login is disabled on this deployment (local_auth_disabled).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'415':
description: Request body was not application/json.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'503':
description: No passkey verifier is wired on this deployment (passkey_unavailable).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
/api/v1/auth/email/start:
post:
tags: [auth]
operationId: loginEmailStart
summary: Begin a passwordless email-OTP login — mail a one-time code (spec §B).
description: >-
Public, pre-session console door: the caller supplies an email and, if it
resolves to a verified account, a one-time code is mailed under the login
purpose. An address with no account returns the SAME 202 with no code minted,
and the per-recipient cooldown is kept on that path too, so probing reveals
nothing (existence is learnt only at the sanctioned /auth/options oracle).
Gated on local_auth_enabled.
x-felis-face: [external]
x-felis-tier: public
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [email]
properties:
email: { type: string, format: email }
responses:
'202':
description: >-
Accepted (neutral): a code was mailed if the address has a verified
account; the response is identical either way.
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: A valid email is required (bad_request).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'403':
description: Local session login is disabled on this deployment (local_auth_disabled).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'415':
description: Request body was not application/json.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'429':
description: A code for this recipient was requested too recently (otp_resend_cooldown).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
/api/v1/auth/email/verify:
post:
tags: [auth]
operationId: loginEmailVerify
summary: Redeem an email-OTP login code into a session (spec §B).
description: >-
Public, pre-session: resolves the address to an account, verifies the code
under the login purpose, and on success mints a host-only felis_session. An
unknown address, a wrong or expired code, and an attempt-exhausted code all
return the IDENTICAL 400 invalid_code, so the door is not an existence or
lockout oracle. Staff are refused (403) — but only AFTER a valid code is
redeemed, so only the account owner can ever reach that refusal.
x-felis-face: [external]
x-felis-tier: public
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [email, code]
properties:
email: { type: string, format: email }
code: { type: string }
responses:
'200':
description: Code accepted; a host-only session cookie is set on the response.
content:
application/json:
schema:
type: object
required: [user_id, role]
properties:
user_id: { type: string }
role: { type: string, enum: [user, admin] }
'400':
description: >-
A valid email and code are required (bad_request); or the code is wrong,
expired, or exhausted (invalid_code, uniform with an unknown address).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'403':
description: >-
Local session login is disabled (local_auth_disabled), or the account is
staff and must sign in at the operator console (staff_account).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'415':
description: Request body was not application/json.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
/api/v1/auth/op-login/start:
post:
tags: [auth]
operationId: opLoginStart
summary: Begin an op.console staff login — mail an OTP, open an approval request (spec §B).
description: >-
Public, pre-session first leg of the two-factor operator door: resolves the
staff address, opens an op_login request, and mails a one-time code under the
op_login purpose, returning the request handle the browser polls. A non-staff
or unknown address gets the SAME 202 with a random, non-persisted handle and no
mail, so this never becomes a staff-enumeration oracle. Gated on
local_auth_enabled.
x-felis-face: [external]
x-felis-tier: public
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [email]
properties:
email: { type: string, format: email }
responses:
'202':
description: >-
Accepted (neutral): a request handle to poll. For a staff address a code
was mailed and the handle is real; otherwise the handle is a random no-op.
content:
application/json:
schema:
type: object
required: [request_id, expires_at]
properties:
request_id: { type: string }
expires_at: { type: string, format: date-time }
'400':
description: A valid email is required (bad_request).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'403':
description: Local session login is disabled on this deployment (local_auth_disabled).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'415':
description: Request body was not application/json.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'429':
description: A code for this recipient was requested too recently (otp_resend_cooldown).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
/api/v1/auth/op-login/status/{id}:
get:
tags: [auth]
operationId: opLoginStatus
summary: Poll whether an op.console login request has been approved in-game (spec §B).
description: >-
Public, pre-session read the browser polls after start. Returns approved:true
only for a genuinely approved, live, unconsumed request; every other case —
unknown, expired, denied, or already-consumed handle — reads approved:false, so
a fabricated handle polls false forever and only an in-game admin vouch can flip
it true.
x-felis-face: [external]
x-felis-tier: public
security: []
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200':
description: The approval state of the request handle.
content:
application/json:
schema:
type: object
required: [approved]
properties:
approved: { type: boolean }
'403':
description: Local session login is disabled on this deployment (local_auth_disabled).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
/api/v1/auth/op-login/finish:
post:
tags: [auth]
operationId: opLoginFinish
summary: Redeem an approved op.console request plus its mailed code into a staff session (spec §B).
description: >-
Public, pre-session final leg: mints a host-only staff session only when BOTH
factors have landed — the request is approved-and-live AND the mailed code
verifies. Every failure (unknown handle, not-yet-approved, wrong or locked code,
lost race) collapses into one uniform 400 op_login_invalid, so a code-less
caller learns nothing. Admin is re-asserted before the session is issued.
x-felis-face: [external]
x-felis-tier: public
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [request_id, code]
properties:
request_id: { type: string }
code: { type: string }
responses:
'200':
description: Both factors proven; a host-only session cookie is set on the response.
content:
application/json:
schema:
type: object
required: [user_id, role]
properties:
user_id: { type: string }
role: { type: string, enum: [user, admin] }
'400':
description: >-
request_id and code are required (bad_request); or the login could not be
completed — unknown handle, not approved, wrong or locked code, or lost
race, all uniform (op_login_invalid).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'403':
description: >-
Local session login is disabled (local_auth_disabled), or the resolved
account is not an operator (staff_account).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'415':
description: Request body was not application/json.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
/api/v1/auth/setup/redeem:
post:
tags: [auth]
operationId: setupRedeem
summary: Redeem a one-time setup token into a lockdown session (spec §B).
description: >-
Public, pre-session first-run door: consumes the one-time setup token minted by
the felis TUI (stored and looked up by SHA-256 hash, like session cookies),
mints a host-only felis_session, and returns the remaining setup steps so the
SPA can drive the wizard. An unknown, consumed, or expired token returns a
uniform 400 setup_token_invalid. Gated on local_auth_enabled.
x-felis-face: [external]
x-felis-tier: public
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [token]
properties:
token: { type: string }
responses:
'200':
description: Token redeemed; a session cookie is set and the setup state is returned.
content:
application/json:
schema:
type: object
required: [user_id, username, role, email, email_verified, has_passkey, setup_required]
properties:
user_id: { type: string }
username: { type: string }
role: { type: string, enum: [user, admin] }
email: { type: string }
email_verified: { type: boolean }
has_passkey: { type: boolean }
setup_required: { type: boolean }
'400':
description: >-
A token is required (bad_request), or it is unknown, already used, or
expired (setup_token_invalid).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'403':
description: Local session login is disabled on this deployment (local_auth_disabled).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'415':
description: Request body was not application/json.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
/api/v1/auth/setup/status:
get:
tags: [auth]
operationId: setupStatus
summary: Report the caller's own setup progress (spec §B).
description: >-
App-tier read the SPA polls after each setup wizard step (email verify, passkey
enroll) to decide whether the first-run lockdown can lift. It reads only the
principal's own state and is reachable during setup lockdown (the rest of the
API is fenced until setup completes).
x-felis-face: [external]
x-felis-tier: app
security: [{ sessionCookie: [] }]
responses:
'200':
description: The caller's current setup state.
content:
application/json:
schema:
type: object
required: [user_id, username, role, email, email_verified, has_passkey, setup_required]
properties:
user_id: { type: string }
username: { type: string }
role: { type: string, enum: [user, admin] }
email: { type: string }
email_verified: { type: boolean }
has_passkey: { type: boolean }
setup_required: { type: boolean }
'401':
$ref: '#/components/responses/Unauthorized'
'404':
description: The principal's user row was not found (not_found).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
@@ -1510,57 +2086,6 @@ paths:
properties:
ok: { type: boolean, const: true }
/api/v1/auth/change-password:
post:
tags: [auth]
operationId: changePassword
summary: Change the caller's local password (forced first-login or rotation).
description: >-
Re-verifies the caller's current password, stores a new bcrypt hash, clears
must_change_password, and revokes the account's OTHER sessions while keeping
the current one (spec §B). Reachable while must_change_password is set, so a
forced first-login change can complete — the rest of the API is fenced off
until it does. The session authenticates the caller; re-asking the current
password additionally blocks a hijacked session from silently rotating the
credential.
x-felis-face: [external]
x-felis-tier: app
security: [{ sessionCookie: [] }]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [current_password, new_password]
properties:
current_password: { type: string, format: password }
new_password:
type: string
format: password
minLength: 8
maxLength: 72
description: 8–72 bytes; 72 is bcrypt's hard input limit.
responses:
'200':
description: Password changed; other sessions revoked.
content:
application/json:
schema:
type: object
required: [ok]
properties:
ok: { type: boolean, const: true }
'400':
description: Weak password, or the new password equals the current one.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
/api/v1/auth/bind:
post:
tags: [auth]
@@ -2061,37 +2586,6 @@ paths:
'404':
$ref: '#/components/responses/NotFound'
/api/v1/users/{id}/reset-password:
post:
tags: [users]
operationId: resetPassword
summary: >-
Generate a high-entropy random password, deliver it to the user's email, and
force a first-login change (admin only). No request body — the server owns
entropy. The password is never returned to the admin; only the target email is echoed.
x-felis-face: [external]
x-felis-tier: owner
security: [{ accessJWT: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Password reset. All existing sessions revoked. Password sent to the user's email.
content:
application/json:
schema:
type: object
required: [ok, email]
properties:
ok: { type: boolean, const: true }
email: { type: string, description: "The recipient email (empty if the user has none)." }
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/api/v1/users/{id}/quotas:
get:
tags: [users]