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

+32 -84
View File
@@ -7,7 +7,6 @@ import type {
CreateServerRequest,
FleetServer,
Identity,
LoginResult,
Phase,
ServerInfo,
WhitelistImage,
@@ -43,7 +42,6 @@ interface MockAccount {
role: Role;
email: string;
linked: boolean;
mustChangePassword: boolean;
emailVerified: boolean;
disabled?: boolean;
created_at?: string;
@@ -104,8 +102,8 @@ interface SessionContext extends RequestContext {
const SESSION_COOKIE = "felis_mock_session";
const ROOT_DOMAIN = "dev.felis.localhost";
const API_BASE = "/api/v1";
const MOCK_PASSWORD = "devpassword";
const MOCK_LINK_CODE = "LINK1234";
const MOCK_OTP_CODE = "123456";
const MC_UUID = "00000000-0000-4000-8000-000000000001";
const RESET_ROUTE = `${API_BASE}/__mock/reset`;
@@ -138,7 +136,7 @@ const LOGIN_HINT_STYLE = `
const LOGIN_HINT_SCRIPT = `
(() => {
const id = "felis-mock-login-hint";
const html = '<aside id="' + id + '" aria-label="Mock sign-in credentials"><strong>Mock sign-in</strong><div>Admin: <code>owner</code> / <code>${MOCK_PASSWORD}</code></div><div>User: <code>user</code> / <code>${MOCK_PASSWORD}</code> (not linked)</div><div>User: <code>linked</code> / <code>${MOCK_PASSWORD}</code> (linked)</div><div>First login: <code>setup</code> / <code>${MOCK_PASSWORD}</code></div><div>Link code: <code>${MOCK_LINK_CODE}</code></div></aside>';
const html = '<aside id="' + id + '" aria-label="Mock sign-in credentials"><strong>Mock sign-in (passwordless)</strong><div>Email OTP: any email / code <code>${MOCK_OTP_CODE}</code> (signs in as <code>owner</code>, admin)</div><div>Link code: <code>${MOCK_LINK_CODE}</code> (signs in as <code>linked</code>, user)</div><div>Passkey: any assertion is accepted (signs in as <code>owner</code>)</div></aside>';
const sync = () => {
const existing = document.getElementById(id);
if (location.pathname === "/login") {
@@ -198,10 +196,9 @@ function mockBackups(): BackupView[] {
function initialState(): MockState {
return {
accounts: {
owner: account("owner", "owner", true, false, false),
user: account("user", "user", false, false, false),
linked: account("linked", "user", true, false, true),
setup: account("setup", "admin", true, true, false),
owner: account("owner", "owner", true, false),
user: account("user", "user", false, false),
linked: account("linked", "user", true, true),
},
images: [
{ image_ref: "registry.felis.svc:5000/paper-1.21:demo", enabled: true, source: "demo" },
@@ -214,29 +211,29 @@ function initialState(): MockState {
],
servers: [
server("survival", "Survival SMP", "Running", "owner", {
players: 12,
maxPlayers: 20,
playersOnline: 12,
playersMax: 20,
autostartPolicy: "public",
}),
server("lobby", "Hub Lobby", "Running", "linked", {
players: 28,
maxPlayers: 60,
playersOnline: 28,
playersMax: 60,
autostartPolicy: "public",
}),
server("creative", "Creative Lab", "Stopped", "user", {
autostartPolicy: "public",
maxPlayers: 16,
playersMax: 16,
}),
server("modded", "Modded Testbed", "Starting", "owner", {
autostartPolicy: "allowlist",
maxPlayers: 12,
playersMax: 12,
}),
server("broken", "Broken Node", "Failed", "user", {
autostartPolicy: "ownerOnly",
maxPlayers: 8,
playersMax: 8,
}),
server("claim-me", "Claimable Node", "Stopped", null, {
maxPlayers: 10,
playersMax: 10,
}),
...generatedServers(),
],
@@ -259,7 +256,7 @@ function initialState(): MockState {
"dupe_glitcher", "griefKing", "nukebot", "AFK_farmer", "chat_spammer",
"xray_cheater", "fly_hacker",
],
// 12 online, matching the server's players:12 — past the search threshold (>8)
// 12 online, matching the server's playersOnline:12 — past the search threshold (>8)
// and a page (>10) so the roster's filter + paging are both exercisable, with a
// few non-whitelisted names to try kick / ban on.
online: [
@@ -398,8 +395,8 @@ function generatedServers(): MockServer[] {
const max = 10 + ((i * 7) % 50);
out.push(
server(`${theme}-${String(n).padStart(2, "0")}`, `${theme} #${n}`, phase, owners[i % owners.length], {
players: phase === "Running" ? 1 + ((i * 3) % max) : 0,
maxPlayers: max,
playersOnline: phase === "Running" ? 1 + ((i * 3) % max) : 0,
playersMax: max,
autostartPolicy: policies[i % policies.length],
}),
);
@@ -416,13 +413,10 @@ function mockStartupMessage(): string {
` API base: ${API_BASE}`,
` Root domain: ${ROOT_DOMAIN}`,
"",
" Accounts:",
` owner / ${MOCK_PASSWORD} admin, linked`,
` user / ${MOCK_PASSWORD} user, not linked`,
` linked / ${MOCK_PASSWORD} user, linked`,
` setup / ${MOCK_PASSWORD} admin, first-login password change`,
"",
` Link code: ${MOCK_LINK_CODE}`,
" Sign-in (passwordless):",
` Email OTP: any email / code ${MOCK_OTP_CODE} → owner (admin, linked)`,
` Link code: ${MOCK_LINK_CODE} → linked (user, linked)`,
" Passkey: any assertion accepted → owner (admin, linked)",
` Reset state: curl -X POST http://127.0.0.1:5173${RESET_ROUTE}`,
"",
].join("\n");
@@ -432,14 +426,12 @@ function account(
id: AccountID,
role: Role,
linked: boolean,
mustChangePassword: boolean,
emailVerified: boolean,
): MockAccount {
return {
id,
role,
linked,
mustChangePassword,
emailVerified,
email: `${id}@mock.felis.local`,
};
@@ -458,8 +450,8 @@ function server(
displayName,
phase,
desiredState: phase === "Stopped" ? "Stopped" : "Running",
players: phase === "Running" ? 1 : 0,
maxPlayers: 20,
playersOnline: phase === "Running" ? 1 : 0,
playersMax: 20,
autostartPolicy: "ownerOnly",
owned: false,
claimable: false,
@@ -515,15 +507,6 @@ function clearSessionCookie(res: ServerResponse): void {
res.setHeader("Set-Cookie", `${SESSION_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax`);
}
function loginAccount(username: string, state: MockState): string | null {
const normalized = username.toLowerCase();
const acc = state.accounts[normalized];
if (acc && !acc.disabled) {
return normalized;
}
return null;
}
function identity(accountInfo: MockAccount): Identity {
return {
user_id: `mock-${accountInfo.id}`,
@@ -531,7 +514,6 @@ function identity(accountInfo: MockAccount): Identity {
role: accountInfo.role,
is_admin: isAdmin(accountInfo.role),
is_owner: isOwner(accountInfo.role),
must_change_password: accountInfo.mustChangePassword,
email_verified: accountInfo.emailVerified,
};
}
@@ -556,8 +538,8 @@ function visibleServers(state: MockState, accountInfo: MockAccount): ServerInfo[
// fleetView projects the internal mock servers into the GET /fleet wire shape
// (the SysAdmin cockpit's read). It is the mock mirror of the Go fleetServerView:
// the CRD field names (playersOnline/playersMax, ready, endpoint*) — NOT the
// me/servers projection's players/maxPlayers — plus the owner joined as the email
// the CRD field names (playersOnline/playersMax, ready, endpoint*) plus the
// runtime `ready`/`endpoint*` fields, and the owner joined as the email
// (COALESCE(email, username) server-side). Endpoint and live player counts are
// gated on Running, exactly as the real cluster reports them.
function fleetView(state: MockState): FleetServer[] {
@@ -572,8 +554,8 @@ function fleetView(state: MockState): FleetServer[] {
autostartPolicy: s.autostartPolicy,
endpointMode: "domain",
endpointAddress: ready ? `10.43.0.${10 + i}:25565` : undefined,
playersOnline: ready ? s.players ?? 0 : 0,
playersMax: s.maxPlayers ?? 0,
playersOnline: ready ? s.playersOnline ?? 0 : 0,
playersMax: s.playersMax ?? 0,
owner: s.owner ? state.accounts[s.owner].email : "",
};
});
@@ -592,7 +574,7 @@ function projectServer(serverInfo: MockServer, accountInfo: MockAccount): Server
function setPhase(serverInfo: MockServer, phase: Phase): void {
serverInfo.phase = phase;
serverInfo.desiredState = phase === "Stopped" ? "Stopped" : "Running";
serverInfo.players = phase === "Running" ? Math.max(serverInfo.players ?? 0, 1) : 0;
serverInfo.playersOnline = phase === "Running" ? Math.max(serverInfo.playersOnline ?? 0, 1) : 0;
}
function policy(value: unknown): AutostartPolicy {
@@ -615,8 +597,8 @@ function createServer(
const created = server(name, req.displayName?.trim() || name, "Stopped", owner, {
subdomain,
players: 0,
maxPlayers: 20,
playersOnline: 0,
playersMax: 20,
autostartPolicy: policy(req.autostartPolicy),
});
state.servers.unshift(created);
@@ -630,23 +612,6 @@ function sendCreateError(res: ServerResponse, code: CreateError): void {
async function handlePublic(ctx: RequestContext): Promise<boolean> {
switch (route(ctx)) {
case "POST auth/login": {
const body = await readJSON<{ username?: string; password?: string }>(ctx.req);
const accountID = body.username ? loginAccount(body.username.trim(), ctx.state) : null;
if (!accountID || body.password !== MOCK_PASSWORD) {
sendError(ctx.res, 403, "invalid_credentials", "invalid mock credentials");
return true;
}
const accountInfo = ctx.state.accounts[accountID];
setSessionCookie(ctx.res, accountID);
const out: LoginResult = {
user_id: `mock-${accountInfo.id}`,
role: accountInfo.role,
must_change_password: accountInfo.mustChangePassword,
};
sendJSON(ctx.res, 200, out);
return true;
}
case "POST auth/bind": {
const body = await readJSON<{ code?: string }>(ctx.req);
const code = body.code?.trim().toUpperCase();
@@ -734,7 +699,7 @@ async function handlePublic(ctx: RequestContext): Promise<boolean> {
}
case "POST auth/email/verify": {
const body = await readJSON<{ email?: string; code?: string }>(ctx.req);
if (!body.email || body.code !== "123456") {
if (!body.email || body.code !== MOCK_OTP_CODE) {
sendError(ctx.res, 400, "invalid_code", "email code is invalid or expired");
return true;
}
@@ -805,10 +770,6 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
}
sendJSON(ctx.res, 200, { servers: fleetView(ctx.state) });
return true;
case "POST auth/change-password":
ctx.account.mustChangePassword = false;
sendJSON(ctx.res, 200, { ok: true });
return true;
case "GET backups":
// Admin sees every archive; a user only worlds they formerly owned — mirrors
// AllBackups vs BackupsForUser. The panel filters by server_name client-side.
@@ -838,7 +799,7 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
}
case "POST account/email/verify": {
const body = await readJSON<{ code?: string }>(ctx.req);
if (body.code?.trim() !== "123456") {
if (body.code?.trim() !== MOCK_OTP_CODE) {
sendError(ctx.res, 400, "invalid_code", "email code is invalid or expired");
return true;
}
@@ -931,7 +892,6 @@ async function handleUserRoute(ctx: SessionContext): Promise<boolean> {
disabled: !!acc.disabled,
email_verified: acc.emailVerified,
server_count: serverCount,
must_change_password: acc.mustChangePassword,
created_at: acc.created_at || new Date().toISOString(),
updated_at: acc.updated_at || new Date().toISOString(),
} as UserView;
@@ -971,7 +931,6 @@ async function handleUserRoute(ctx: SessionContext): Promise<boolean> {
role: body.role || "user",
email: body.email || `${username}@example.com`,
linked: false,
mustChangePassword: body.must_change_password ?? false,
emailVerified: true,
disabled: false,
created_at: new Date().toISOString(),
@@ -996,7 +955,6 @@ async function handleUserRoute(ctx: SessionContext): Promise<boolean> {
disabled: false,
email_verified: true,
server_count: 0,
must_change_password: newAcc.mustChangePassword,
created_at: newAcc.created_at,
updated_at: newAcc.updated_at,
} as UserView);
@@ -1033,7 +991,6 @@ async function handleUserRoute(ctx: SessionContext): Promise<boolean> {
disabled: !!acc.disabled,
email_verified: acc.emailVerified,
server_count: serverCount,
must_change_password: acc.mustChangePassword,
created_at: acc.created_at || new Date().toISOString(),
updated_at: acc.updated_at || new Date().toISOString(),
linked_accounts,
@@ -1069,7 +1026,6 @@ async function handleUserRoute(ctx: SessionContext): Promise<boolean> {
disabled: !!acc.disabled,
email_verified: acc.emailVerified,
server_count: serverCount,
must_change_password: acc.mustChangePassword,
created_at: acc.created_at || new Date().toISOString(),
updated_at: acc.updated_at,
} as UserView);
@@ -1119,14 +1075,6 @@ async function handleUserRoute(ctx: SessionContext): Promise<boolean> {
return true;
}
// POST /api/v1/users/{id}/reset-password
if (is("POST", ctx) && subAction === "reset-password") {
acc.mustChangePassword = true;
acc.updated_at = new Date().toISOString();
sendJSON(ctx.res, 200, { ok: true, email: acc.email || "" });
return true;
}
// GET /api/v1/users/{id}/quotas
if (is("GET", ctx) && subAction === "quotas") {
if (!acc.quota) {
@@ -1791,7 +1739,7 @@ function handleAccessMock(ctx: SessionContext, serverInfo: MockServer): boolean
return true;
}
if (is("GET", ctx) && sub === "players") {
const max = serverInfo.maxPlayers ?? 0;
const max = serverInfo.playersMax ?? 0;
sendJSON(ctx.res, 200, {
name: serverInfo.name,
online: access.online.length,