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

+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":