feat(auth)!: go fully passwordless and fix cross-check review findings

Remove password authentication everywhere; the only session doors are
passkey (WebAuthn), email OTP, in-game bind codes, QR scan-login, and
op-login vouching. Remediates the 33-finding cross-check review across
backend, CLI, panel, plugins, and docs.

Backend/CLI:
- Drop password routes and fields from account/user/onboard/auth
  handlers; align tests (new account subtests, naming reserves
  "console", op-login/onboard/qr-login test updates).
- Add migrations 0016_op_login.sql and 0017_drop_password.sql.
- Thread panel/admin hostnames from hostcfg through api.go,
  setup_panel.go, tui_root.go and tui_preflight.go instead of
  hardcoding; bootstrap.sh writes panel-hostname/admin-hostname
  into felis.toml.
- Reword breakglass and TUI copy for passwordless flows.

Panel:
- Delete the ChangePassword page and all password UI; align
  login/auth/api/types with the passwordless contract; add the
  migration and op-login approval flows.
- i18n: convert ImageBuildPage durations/status badges and
  ServerLuckPerms strings to translation keys; drop 72 orphan keys
  per locale; unify the title as "Felis - Console".

Plugins (all six rebuilt):
- Velocity waiting router returns 503 at_capacity during wake;
  MOTD/control-channel copy and config comments.
- Paper zh menu title; Limbo bind-code TTL 600s with panel_url
  preference; unified /link lines in fabric/forge/neoforge; shared
  link-client javadoc contract fixes.

Docs: openapi.yaml, sequence-diagrams.md, deploy/limbo/README.md and
plugins/README.md aligned with the implementation.

BREAKING CHANGE: migration 0017 irreversibly drops
users.password_hash and users.must_change_password; password login
cannot be restored after migrating.
This commit is contained in:
flyemoji committed 2026-07-20 04:47:32 +09:00
1 parent c96b36a41f
commit 7860152f57
97 files changed
+1923 -1444

No files matched your search

+13 -10
View File
@@ -10,14 +10,15 @@ import (
// op.console STAFF login (spec §B op-login): the two-factor door for the most
// sensitive tier. Unlike the console.<root_domain> player doors (email OTP / bind
// code), a staff web session is never minted from a single factor. The flow is a
// three-call state machine over op_login_requests (migration 0012), all Public
// three-call state machine over op_login_requests (migration 0016), all Public
// pre-session routes (the caller has no principal yet), plus two internal-face routes
// velocity drives on behalf of online admins:
// for the in-game side (approve is driven by velocity's /felis command; pending has
// no consumer yet — see handleOpLoginPending):
//
// POST /api/v1/auth/op-login/start (public) — mint a request + mail an OTP
// GET /api/v1/auth/op-login/status/{id} (public) — poll until an admin approves
// POST /api/v1/auth/op-login/finish (public) — redeem code+approval → session
// GET /api/v1/internal/op-login/pending (internal) — the online-admin push list
// GET /api/v1/internal/op-login/pending (internal) — list requests awaiting a vouch
// POST /api/v1/internal/op-login/{id}/approve (internal) — an in-game admin vouches
//
// The two factors:
@@ -26,9 +27,9 @@ import (
// start and redeemed by finish, reusing the email_otps lifecycle (the purpose
// column keeps it from ever colliding with a console login_email or onboard code).
// - An in-game vouch — an already-trusted admin who is ONLINE approves the pending
// request via velocity's /felis command (internal approve). Only a linked
// role=admin account may approve; velocity additionally gates the command on
// in-game op, so the API check is defence in depth over its own user table.
// request via velocity's /felis command (internal approve). The API's own user
// table is the sole authority: only a UUID linked to a role=admin account may
// approve (velocity's command runs for any player and relies on this check).
//
// finish mints the session only when BOTH have landed. Neither factor alone — a mailed
// code without an approval, or an approval without the code — yields a session.
@@ -317,8 +318,10 @@ func (a *API) handleOpLoginFinish(w http.ResponseWriter, r *http.Request) {
}
// handleOpLoginPending lists live pending staff login requests, oldest first (internal
// face). Velocity polls it and pushes the waiting requests to online admins, who
// approve one with /felis web op approve <id>. Internal-only: velocity holds a service
// face). Today no plugin consumes it: the approver learns the request id out-of-band
// (the op.console start screen shows it to the person logging in) and runs
// /felis web op approve <id>. The route exists so velocity can later push the waiting
// list to online admins without an API change. Internal-only: velocity holds a service
// token and no pending request is secret to the operator crew.
func (a *API) handleOpLoginPending(w http.ResponseWriter, r *http.Request) {
reqs, err := a.Repo.ListPendingOpLogins(r.Context(), a.now())
@@ -340,8 +343,8 @@ func (a *API) handleOpLoginPending(w http.ResponseWriter, r *http.Request) {
// opLoginApproveRequest is the internal approve body: the online-mode UUID of the
// in-game admin running /felis web op approve. The API resolves it to a linked account
// and refuses unless that account is role=admin — defence in depth over velocity's own
// in-game op gate, checked against the API's authoritative user table.
// and refuses unless that account is role=admin — this check against the API's
// authoritative user table is the only gate; velocity's command itself is unprivileged.
type opLoginApproveRequest struct {
ApproverUUID string `json:"approver_uuid"`
}