0.2.38: optional two-factor authentication (authenticator-app TOTP)

Per-user opt-in 2FA: enroll from the Two-factor option next to Change
password (QR + confirm code + 8 one-time recovery codes), login becomes
two-step for enrolled users, disable requires the account password.
Escape hatch for lost phones: reset-2fa CLI + Reset Two-Factor StartOS
action. Second-factor guesses share the login rate limiter; the pending
login window expires after 5 minutes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jonathan Kirkwood
2026-07-11 22:08:04 +02:00
co-authored by Claude Fable 5
parent 053bfeab23
commit 0822eca887
20 changed files with 846 additions and 41 deletions
+30 -1
View File
@@ -44,9 +44,15 @@ export interface User {
is_active: boolean;
is_service_admin: boolean;
primary_account_id: number | null;
totp_enabled: boolean;
created_at: string;
}
/** Password was accepted but the account needs its authenticator code to finish signing in. */
export interface LoginPending2FA {
requires_2fa: true;
}
export interface LinkedAccount {
id: number;
name: string;
@@ -279,11 +285,17 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
export const api = {
login: (login: string, password: string) =>
request<User>("/api/auth/login", {
request<User | LoginPending2FA>("/api/auth/login", {
method: "POST",
body: JSON.stringify({ login, password }),
}),
verifyTotp: (code: string) =>
request<User>("/api/auth/login/verify-totp", {
method: "POST",
body: JSON.stringify({ code }),
}),
logout: () => request<{ status: string }>("/api/auth/logout", { method: "POST" }),
me: () => request<User>("/api/auth/me"),
@@ -294,6 +306,23 @@ export const api = {
body: JSON.stringify({ current_password, new_password }),
}),
totpSetup: () =>
request<{ secret: string; otpauth_uri: string; qr_svg: string }>("/api/auth/totp/setup", {
method: "POST",
}),
totpConfirm: (code: string) =>
request<{ recovery_codes: string[] }>("/api/auth/totp/confirm", {
method: "POST",
body: JSON.stringify({ code }),
}),
totpDisable: (password: string) =>
request<{ status: string }>("/api/auth/totp/disable", {
method: "POST",
body: JSON.stringify({ password }),
}),
// Entities
listEntities: () => request<Entity[]>("/api/entities"),
listEntityRollup: () =>