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

+14 -14
View File
@@ -12,11 +12,12 @@ func statusPath(mcUUID string) string {
// TestQRLoginCompletionPollVertical walks the QR scan-to-login flow end to end and
// proves its load-bearing invariant: the internal completion poll reports the link
// only after the WEB verify writes it, and reports it bound to the exact Principal
// that verified — never to a UUID the poll itself could name. velocity mints and
// polls on the internal face (it holds no web Principal); the durable bind is born
// on the external face from a logged-in user. That split is the whole security
// model of the scan, so the test drives both faces of one API.
// only after the WEB verify writes it. velocity mints and polls on the internal
// face (it holds no web Principal); the durable bind is born on the external face
// from a logged-in user. That split is the whole security model of the scan, so
// the test drives both faces of one API. The poll carries ONLY the boolean — the
// plugin keys everything on the UUID it already holds, so no identity detail
// (user_id) ever crosses back, in either state.
func TestQRLoginCompletionPollVertical(t *testing.T) {
const mcUUID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
user := &Principal{UserID: "u-scan", Email: "[email protected]", Role: "user"}
@@ -54,9 +55,8 @@ func TestQRLoginCompletionPollVertical(t *testing.T) {
t.Fatalf("verify: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
// Now the poll flips: velocity sees linked:true and the user_id it must bind the
// in-game session to — and that user_id is the verifier's, the only identity the
// poll could ever return, since the poll cannot mint a link of its own.
// Now the poll flips: velocity sees linked:true and admits the player. The
// response stays identity-free — linked is the entire contract.
w = do(ih, "GET", statusPath(mcUUID), "", nil)
if w.Code != http.StatusOK {
t.Fatalf("post-verify poll: code = %d, want 200 (%s)", w.Code, w.Body.String())
@@ -65,8 +65,8 @@ func TestQRLoginCompletionPollVertical(t *testing.T) {
if b["linked"] != true {
t.Fatalf("post-verify poll body = %v, want linked:true", b)
}
if got := b["user_id"]; got != user.UserID {
t.Fatalf("post-verify poll user_id = %v, want %q (the verifier's id)", got, user.UserID)
if _, ok := b["user_id"]; ok {
t.Fatalf("post-verify poll leaked user_id: %v", b)
}
}
@@ -101,8 +101,8 @@ func TestQRLoginStatusIdempotent(t *testing.T) {
t.Fatalf("poll %d: code = %d, want 200 (%s)", i, w.Code, w.Body.String())
}
b := acctBody(t, w)
if b["linked"] != true || b["user_id"] != "u-held" {
t.Fatalf("poll %d body = %v, want linked:true user_id:u-held", i, b)
if b["linked"] != true {
t.Fatalf("poll %d body = %v, want linked:true", i, b)
}
}
// The read must not have disturbed the durable link.
@@ -112,8 +112,8 @@ func TestQRLoginStatusIdempotent(t *testing.T) {
}
// TestQRLoginStatusFaceSeparation enforces that the poll is internal-only. It
// reads who a UUID is linked to — a fact the public web face must not be able to
// fish out by UUID — so crossing onto the external face must 404, not answer.
// reads whether a UUID is linked — a fact the public web face must not be able
// to fish out by UUID — so crossing onto the external face must 404, not answer.
func TestQRLoginStatusFaceSeparation(t *testing.T) {
user := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
api := newTestAPI(newFakeRepo(), newFakeCluster())