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",
});
});
});
+63 -26
View File
@@ -12,7 +12,6 @@ import type {
KickResult,
LinkResult,
LinkStatus,
LoginResult,
BindResult,
PatchUserRequest,
PlayersResult,
@@ -108,13 +107,10 @@ export interface SetupState {
}
export const api = {
// Local-password auth (spec §B1). login sets an HttpOnly session cookie as a
// side effect — the panel never sees it — and returns only what to route on next
// (must_change_password forces the change card before any other surface). The
// username/password pair is the ONLY local credential; Passkey/PWA are Phase
// B2/C. login may 403 `local_auth_disabled` on a Zero-Trust-only deployment.
login: (username: string, password: string) =>
request<LoginResult>("POST", "/auth/login", { username, password }),
// Session doors (spec §B). The product is passwordless: a session is minted only
// by passkey, email-OTP, bind code, or the op-login vouch flow below. Every door
// sets an HttpOnly cookie as a side effect and may 403 `local_auth_disabled` on a
// Zero-Trust-only deployment.
// logout is idempotent server-side (clears the session row + cookie); calling it
// without a session still resolves 200. After it, refreshing /me yields 401, which
@@ -142,14 +138,21 @@ export const api = {
authPasskeyDiscoverableFinish: (login_id: string, assertion: any) =>
request<any>("POST", "/auth/passkey/login/discoverable/finish", { login_id, assertion }),
// changePassword is callable during the first-login lockdown (the route is
// AllowDuringPasswordChange): the server re-verifies current_password, rejects an
// unchanged or weak (8–72 byte) new password, writes the new hash, and revokes
// every OTHER session. The caller's own session is kept, so no re-login is needed.
changePassword: (current_password: string, new_password: string) =>
request<{ ok: boolean }>("POST", "/auth/change-password", {
current_password,
new_password,
// Op-login (spec §B): the staff door. start mails an OTP to a staff address and
// returns a request handle; an online admin vouches in-game with
// `/felis web op approve <request_id>`; the panel polls status until approved,
// then finish redeems {request_id, code} into a session. start answers 202 with a
// request_id for ANY well-formed address (anti-enumeration), so the UI just waits.
opLoginStart: (email: string) =>
request<{ request_id: string; expires_at: string }>("POST", "/auth/op-login/start", { email }),
opLoginStatus: (id: string) =>
request<{ approved: boolean }>("GET", `/auth/op-login/status/${encodeURIComponent(id)}`),
opLoginFinish: (request_id: string, code: string) =>
request<{ user_id: string; role: string }>("POST", "/auth/op-login/finish", {
request_id,
code,
}),
// Setup bootstrap (spec §B). redeem consumes the one-time token from the setup URL
@@ -369,6 +372,46 @@ export const api = {
passkeyDelete: (id: string) =>
request<void>("DELETE", `/account/passkey/credentials/${id}`),
// Account migration (spec §B3 inherit). Started in-game with /felis migrate; the
// web side then drives: status → step-up confirm (passkey when enrolled, email-OTP
// otherwise) → issue-code (source names the target account and reads the one-time
// code) → redeem (the TARGET account spends the code; the source's servers move to
// it and the source is retired).
migrateStatus: () =>
request<{
active: boolean;
state?: string;
target_user_id?: string;
confirm_factor?: string;
code_expires_at?: string;
}>("GET", "/account/migrate"),
migrateConfirmOTPStart: () =>
request<{ sent: boolean; expires_at: string }>("POST", "/account/migrate/confirm/otp/start"),
migrateConfirmOTPVerify: (code: string) =>
request<{ confirmed: boolean }>("POST", "/account/migrate/confirm/otp/verify", { code }),
migrateConfirmPasskeyBegin: () =>
request<any>("POST", "/account/migrate/confirm/passkey/begin"),
migrateConfirmPasskeyFinish: (assertion: any) =>
request<{ confirmed: boolean }>("POST", "/account/migrate/confirm/passkey/finish", {
assertion,
}),
migrateIssueCode: (target_user_id: string) =>
request<{ code: string; expires_at: string }>("POST", "/account/migrate/issue-code", {
target_user_id,
}),
migrateRedeem: (code: string) =>
request<{ migrated: boolean; servers_moved: number; servers: string[] }>(
"POST",
"/account/migrate/redeem",
{ code },
),
listSubmissions: () =>
request<{ submissions: Submission[] }>("GET", "/submissions").then((r) => r.submissions ?? []),
@@ -429,9 +472,6 @@ export const api = {
disableUser: (id: string, disabled: boolean) =>
request<{ id: string; disabled: boolean }>("POST", `/users/${id}/disable`, { disabled }),
resetUserPassword: (id: string) =>
request<{ ok: boolean; email: string }>("POST", `/users/${id}/reset-password`),
getUserQuotas: (id: string) => request<QuotaView>("GET", `/users/${id}/quotas`),
setUserQuotas: (id: string, quotas: QuotaInput) =>
@@ -496,15 +536,12 @@ export function humanizeError(e: unknown): string {
const err = e as Partial<ApiError>;
switch (err.code) {
// Local-password auth (spec §B1).
// Session doors (spec §B): every passwordless door 403s this when local
// sessions are disabled on a Zero-Trust-only deployment.
case "local_auth_disabled":
return t("local_auth_disabled");
case "invalid_credentials":
return t("invalid_credentials");
case "weak_password":
return t("weak_password");
case "password_unchanged":
return t("password_unchanged");
case "staff_account":
return t("staff_account");
case "not_linked":
return t("not_linked");
case "invalid_code":
+3 -13
View File
@@ -2,8 +2,8 @@ import { describe, it, expect } from "vitest";
import { deriveAuth, isUnauthorized } from "./auth";
import type { Identity } from "./types";
// deriveAuth is the load-bearing auth decision: it decides who is bounced to /login,
// who is forced through the change-password card, and — critically — who is KEPT in
// deriveAuth is the load-bearing auth decision: it decides who is bounced to /login
// and — critically — who is KEPT in
// the app despite a /me failure. The one distinction that must never blur is a true
// 401 (no session → login) versus any other failure (transient → stay functional),
// because mistaking the latter for the former would log out a healthy Zero-Trust
@@ -14,7 +14,6 @@ const admin: Identity = {
email: "[email protected]",
role: "admin",
is_admin: true,
must_change_password: false,
is_owner: false,
};
@@ -41,7 +40,6 @@ describe("deriveAuth", () => {
expect(s.loading).toBe(true);
expect(s.unauthenticated).toBe(false);
expect(s.isAdmin).toBe(false);
expect(s.mustChangePassword).toBe(false);
});
it("a settled 401 with no identity is unauthenticated (→ /login)", () => {
@@ -61,21 +59,13 @@ describe("deriveAuth", () => {
const s = deriveAuth(admin, null, false);
expect(s.unauthenticated).toBe(false);
expect(s.isAdmin).toBe(true);
expect(s.mustChangePassword).toBe(false);
});
it("surfaces must_change_password from the identity", () => {
const s = deriveAuth({ ...admin, must_change_password: true }, null, false);
expect(s.mustChangePassword).toBe(true);
expect(s.unauthenticated).toBe(false);
});
it("fails closed on a malformed identity missing is_admin / must_change_password", () => {
it("fails closed on a malformed identity missing is_admin", () => {
// Mirrors the wire-shape trap: absent fields are undefined, not thrown access.
const partial = { user_id: "u", email: "e", role: "user" } as unknown as Identity;
const s = deriveAuth(partial, null, false);
expect(s.isAdmin).toBe(false);
expect(s.mustChangePassword).toBe(false);
expect(s.unauthenticated).toBe(false);
});
});
+2 -5
View File
@@ -1,7 +1,7 @@
import type { ApiError, Identity } from "./types";
// Pure auth-state derivation, kept out of tier.tsx so it can be pinned without a
// React renderer (mirrors lib/nav.ts). The whole local-password gate turns on one
// React renderer (mirrors lib/nav.ts). The whole session gate turns on one
// distinction the rest of the app routes on: a /me that returns 401 means "there
// is genuinely no session — show the login page", whereas ANY OTHER /me failure
// (network, 5xx, timeout) must NOT log the user out. The latter preserves the
@@ -20,8 +20,6 @@ export interface AuthState {
/** True ONLY when /me returned 401 — no/expired session, route to /login. A
* transient or 5xx failure leaves this false so the app keeps rendering. */
unauthenticated: boolean;
/** True when the loaded identity still owes a forced first-login change. */
mustChangePassword: boolean;
}
/** isUnauthorized reports whether a caught error is the request() 401 envelope —
@@ -39,7 +37,7 @@ export function isUnauthorized(error: unknown): boolean {
/** deriveAuth folds one /me outcome (identity OR error, plus the in-flight flag)
* into the state the router reads. Every boolean is computed with `=== true` / an
* explicit 401 check so an absent or malformed field fails to the safe side:
* non-admin, still-authenticated, no forced change. */
* non-admin, still-authenticated. */
export function deriveAuth(
identity: Identity | null,
error: unknown,
@@ -50,6 +48,5 @@ export function deriveAuth(
loading,
isAdmin: identity?.is_admin === true,
unauthenticated: !loading && identity === null && isUnauthorized(error),
mustChangePassword: identity?.must_change_password === true,
};
}
+6
View File
@@ -6,6 +6,10 @@
export interface RuntimeConfig {
apiBase: string;
rootDomain: string;
/** Player-console hostname (console.<root>), absent when unconfigured. */
panelHostname?: string;
/** Operator-console hostname (op.console.<root>), absent when unconfigured. */
adminHostname?: string;
}
const FALLBACK: RuntimeConfig = {
@@ -26,6 +30,8 @@ export async function loadConfig(): Promise<RuntimeConfig> {
cached = {
apiBase: raw.apiBase ?? FALLBACK.apiBase,
rootDomain: raw.rootDomain ?? FALLBACK.rootDomain,
panelHostname: raw.panelHostname,
adminHostname: raw.adminHostname,
};
} catch {
cached = FALLBACK;
+2 -4
View File
@@ -26,9 +26,8 @@ import { deriveAuth, type AuthState } from "./auth";
// simply don't see admin surfaces. (The backend 403s admin data calls
// independently, so this is safe.) Only a genuine 401 sets `unauthenticated`.
//
// 3. Login-aware: `unauthenticated` (a true 401) routes to /login;
// `mustChangePassword` forces the change-password card; `refresh()` re-reads /me
// after a login / change / logout so the gate re-evaluates without a reload.
// 3. Login-aware: `unauthenticated` (a true 401) routes to /login; `refresh()`
// re-reads /me after a login / logout so the gate re-evaluates without a reload.
//
// Rules 1–2 are UX truth, not a security control — see DESIGN-WEB-3SIDES §1.
@@ -47,7 +46,6 @@ const TierContext = createContext<TierState>({
isAdmin: false,
isOwner: false,
unauthenticated: false,
mustChangePassword: false,
refresh: async () => {},
});
+8 -25
View File
@@ -25,8 +25,8 @@ export interface ServerInfo {
displayName?: string;
phase: Phase;
desiredState?: "Running" | "Stopped";
players?: number;
maxPlayers?: number;
playersOnline?: number;
playersMax?: number;
autostartPolicy?: AutostartPolicy;
/** Whether the caller may claim this server (unowned + linked + quota). */
claimable?: boolean;
@@ -74,7 +74,7 @@ export interface AccessResult {
}
/** PlayersResult projects GET /servers/{name}/access/players (spec §7 access), the
* ONLY source of WHO is online — ServerInfo.players carries the count alone.
* ONLY source of WHO is online — ServerInfo.playersOnline carries the count alone.
* `online`/`max` are the tally; `players` is a BEST-EFFORT parse of the vanilla
* "list" reply (parseListOutput) and, like the whitelist, can come back empty on a
* non-vanilla format while `output` (the raw RCON text, ground truth) still names
@@ -101,10 +101,9 @@ export interface KickResult {
* projection plus the owner joined read-only from Postgres for display.
*
* It is a DISTINCT type from ServerInfo, not a reuse: /fleet emits the raw CRD
* shape — `playersOnline`/`playersMax` (not players/maxPlayers), plus `ready` and
* the `endpoint*` runtime fields — whereas ServerInfo is the /me/servers
* projection. Sharing one interface would silently read `undefined` across the
* fetch().json() boundary for every renamed field. */
* shape — `ready` and the `endpoint*` runtime fields, with playersOnline/playersMax
* required — whereas ServerInfo is the /me/servers projection with them optional.
* Sharing one interface would blur which fields each face actually guarantees. */
export interface FleetServer {
name: string;
subdomain: string;
@@ -213,24 +212,9 @@ export interface Identity {
/** Server-computed Principal.IsOwner() — true only for the platform-level
* owner account (one above admin). Owners get user management; admins don't. */
is_owner: boolean;
/** Local-password path only: the account owes a forced first-login password
* change. The JWT/Access path always leaves it false. Like `is_admin` it crosses
* the untyped fetch().json() boundary, so consumers MUST compare `=== true` — an
* absent field is `undefined` (correctly "no change owed"), never a thrown access. */
must_change_password: boolean;
email_verified?: boolean;
}
/** LoginResult mirrors POST /api/v1/auth/login (handlers_auth.go handleLogin). The
* session cookie is set as a side effect (HttpOnly, so the panel never sees it);
* the body carries only what the panel routes on next — chiefly whether to force the
* change-password card before any other surface. */
export interface LoginResult {
user_id: string;
role: "user" | "admin" | "owner";
must_change_password: boolean;
}
export interface BindResult {
user_id: string;
linked: boolean;
@@ -288,7 +272,6 @@ export interface UserView {
disabled: boolean;
email_verified: boolean;
server_count: number;
must_change_password: boolean;
created_at: string;
updated_at: string;
}
@@ -304,12 +287,12 @@ export interface UserDetail extends UserView {
linked_accounts: LinkedAccount[];
}
/** CreateUserRequest mirrors handlers_users.go createUserRequest — passwordless:
* the new account signs in via email-OTP / passkey / bind code, never a password. */
export interface CreateUserRequest {
username: string;
email?: string;
role: "admin" | "user";
password: string;
must_change_password: boolean;
}
export interface PatchUserRequest {