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

+101 -60
View File
@@ -62,57 +62,12 @@ describe("api.me wire shape", () => {
expect((opts as RequestInit).method).toBe("GET");
expect((opts as RequestInit).credentials).toBe("include");
});
it("surfaces must_change_password from GET /me verbatim", async () => {
// handleMe always emits must_change_password; the forced-change gate routes on
// it, so the snake_case key must survive the untyped boundary unchanged.
const body = {
user_id: "u4",
email: "[email protected]",
role: "admin",
is_admin: true,
must_change_password: true,
};
vi.stubGlobal("fetch", fakeFetch(body));
const id = await api.me();
expect(id.must_change_password).toBe(true);
});
});
describe("local-password auth wire shapes", () => {
describe("session auth wire shapes", () => {
beforeEach(() => vi.restoreAllMocks());
afterEach(() => vi.unstubAllGlobals());
it("login POSTs {username, password} and returns must_change_password", async () => {
// EXACTLY handlers_auth.go handleLogin's request body and response.
const fetchSpy = fakeFetch({
user_id: "u1",
role: "admin",
must_change_password: true,
});
vi.stubGlobal("fetch", fetchSpy);
const res = await api.login("owner", "s3cret");
expect(res.must_change_password).toBe(true);
expect(res.user_id).toBe("u1");
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
.calls[0];
expect(String(url)).toBe("/auth/login");
expect((opts as RequestInit).method).toBe("POST");
expect((opts as RequestInit).credentials).toBe("include");
// The Go login route now REQUIRES Content-Type: application/json (it 415s any
// other type to kill the cross-site form-POST forgery vector). This pins the
// panel half of that contract: a refactor that drops the header silently breaks
// login, and only this assertion would catch it.
expect((opts as RequestInit).headers).toEqual({
"Content-Type": "application/json",
});
expect(JSON.parse((opts as RequestInit).body as string)).toEqual({
username: "owner",
password: "s3cret",
});
});
it("logout POSTs to /auth/logout (idempotent {ok:true})", async () => {
const fetchSpy = fakeFetch({ ok: true });
vi.stubGlobal("fetch", fetchSpy);
@@ -150,31 +105,117 @@ describe("local-password auth wire shapes", () => {
});
});
it("changePassword POSTs {current_password, new_password}", async () => {
const fetchSpy = fakeFetch({ ok: true });
it("maps the auth error codes to stable human copy", async () => {
const { humanizeError } = await import("./api");
expect(humanizeError({ code: "local_auth_disabled" })).toMatch(/turned off/i);
expect(humanizeError({ code: "staff_account" })).toMatch(/operator/i);
});
// Op-login (the staff door): start hands back the approval handle the panel shows
// as `/felis web op approve <id>`; status is polled; finish spends the mailed code.
// EXACTLY handlers_op_login.go's request/response keys.
it("opLoginStart POSTs {email} and surfaces {request_id, expires_at}", async () => {
const fetchSpy = fakeFetch({
request_id: "req-1",
expires_at: "2026-07-19T00:10:00Z",
});
vi.stubGlobal("fetch", fetchSpy);
await api.changePassword("old-pw", "brand-new-pw");
const res = await api.opLoginStart("o[email protected]");
expect(res.request_id).toBe("req-1");
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
.calls[0];
expect(String(url)).toBe("/auth/change-password");
expect(String(url)).toBe("/auth/op-login/start");
expect((opts as RequestInit).method).toBe("POST");
// Same JSON content-type contract as login — the change-password route guards on
// it too (defense-in-depth), so the panel must keep sending it.
expect((opts as RequestInit).headers).toEqual({
"Content-Type": "application/json",
});
expect(JSON.parse((opts as RequestInit).body as string)).toEqual({
current_password: "old-pw",
new_password: "brand-new-pw",
email: "o[email protected]",
});
});
it("maps the auth error codes to stable human copy", async () => {
const { humanizeError } = await import("./api");
expect(humanizeError({ code: "invalid_credentials" })).toMatch(/incorrect/i);
expect(humanizeError({ code: "local_auth_disabled" })).toMatch(/turned off/i);
expect(humanizeError({ code: "weak_password" })).toMatch(/8 and 72/);
expect(humanizeError({ code: "password_unchanged" })).toMatch(/differ/i);
it("opLoginStatus GETs /auth/op-login/status/{id} and surfaces approved", async () => {
const fetchSpy = fakeFetch({ approved: true });
vi.stubGlobal("fetch", fetchSpy);
const res = await api.opLoginStatus("req-1");
expect(res.approved).toBe(true);
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
.calls[0];
expect(String(url)).toBe("/auth/op-login/status/req-1");
expect((opts as RequestInit).method).toBe("GET");
});
it("opLoginFinish POSTs {request_id, code}", async () => {
const fetchSpy = fakeFetch({ user_id: "u9", role: "admin" });
vi.stubGlobal("fetch", fetchSpy);
const res = await api.opLoginFinish("req-1", "123456");
expect(res.user_id).toBe("u9");
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
.calls[0];
expect(String(url)).toBe("/auth/op-login/finish");
expect((opts as RequestInit).method).toBe("POST");
expect(JSON.parse((opts as RequestInit).body as string)).toEqual({
request_id: "req-1",
code: "123456",
});
});
});
// Pin the §B3 migration wire shapes (handlers_account_migrate.go). The status union
// ({active:false} | {active:true, state, ...}) and the issue/redeem bodies cross the
// untyped fetch().json() boundary, so a key drift leaves the Account migration card
// inert while typecheck/build stay green.
describe("account migration wire shapes", () => {
beforeEach(() => vi.restoreAllMocks());
afterEach(() => vi.unstubAllGlobals());
it("migrateStatus GETs /account/migrate and surfaces the state-machine fields", async () => {
const fetchSpy = fakeFetch({
active: true,
state: "confirmed",
confirm_factor: "email_otp",
});
vi.stubGlobal("fetch", fetchSpy);
const res = await api.migrateStatus();
expect(res.active).toBe(true);
expect(res.state).toBe("confirmed");
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
.calls[0];
expect(String(url)).toBe("/account/migrate");
expect((opts as RequestInit).method).toBe("GET");
expect((opts as RequestInit).credentials).toBe("include");
});
it("migrateIssueCode POSTs {target_user_id} and surfaces the one-time code", async () => {
const fetchSpy = fakeFetch({
code: "MIGR-1234",
expires_at: "2026-07-19T00:10:00Z",
});
vi.stubGlobal("fetch", fetchSpy);
const res = await api.migrateIssueCode("u2");
expect(res.code).toBe("MIGR-1234");
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
.calls[0];
expect(String(url)).toBe("/account/migrate/issue-code");
expect((opts as RequestInit).method).toBe("POST");
expect(JSON.parse((opts as RequestInit).body as string)).toEqual({
target_user_id: "u2",
});
});
it("migrateRedeem POSTs {code} and surfaces the moved servers", async () => {
const fetchSpy = fakeFetch({ migrated: true, servers_moved: 2, servers: ["a", "b"] });
vi.stubGlobal("fetch", fetchSpy);
const res = await api.migrateRedeem("MIGR-1234");
expect(res.servers_moved).toBe(2);
expect(res.servers).toEqual(["a", "b"]);
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
.calls[0];
expect(String(url)).toBe("/account/migrate/redeem");
expect((opts as RequestInit).method).toBe("POST");
expect(JSON.parse((opts as RequestInit).body as string)).toEqual({
code: "MIGR-1234",
});
});
});