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:
97 files changed
+1923
-1444
No files matched your search
@@ -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())
|
||||
|
||||
Reference in new issue
Block a user