feat: Two-Factor Authentication (TOTP, Email OTP) and OIDC/SSO – full implementation with admin UI (#933)

feat: Two-Factor Authentication (TOTP, Email OTP) and OIDC/SSO – full implementation with admin UI (#933)
This commit is contained in:
Sn0rrii
2026-04-13 13:24:28 +02:00
committed by GitHub
parent 8af0966e68
commit ba1c97c808
44 changed files with 10473 additions and 290 deletions
+173 -6
View File
@@ -3,13 +3,21 @@ import type { ArchivePlatesResponse, LibraryFilePlatesResponse } from '../types/
const API_BASE = '/api/v1';
// Auth token storage
let authToken: string | null = localStorage.getItem('auth_token');
// By default tokens are stored in sessionStorage (tab-scoped, cleared on close).
// When the token originates from the ?token= URL param (kiosk bootstrap), it is
// additionally persisted in localStorage so the kiosk survives page reloads.
let authToken: string | null =
sessionStorage.getItem('auth_token') ?? localStorage.getItem('auth_token');
export function setAuthToken(token: string | null) {
export function setAuthToken(token: string | null, persist = false) {
authToken = token;
if (token) {
localStorage.setItem('auth_token', token);
sessionStorage.setItem('auth_token', token);
if (persist) {
localStorage.setItem('auth_token', token);
}
} else {
sessionStorage.removeItem('auth_token');
localStorage.removeItem('auth_token');
}
}
@@ -66,6 +74,7 @@ async function request<T>(
const response = await fetch(`${API_BASE}${endpoint}`, {
...options,
cache: 'no-store', // Prevent browser caching of API responses
credentials: 'include', // Required for HttpOnly cookies (e.g. 2fa_challenge)
headers,
});
@@ -2346,9 +2355,13 @@ export interface LoginRequest {
}
export interface LoginResponse {
access_token: string;
token_type: string;
user: UserResponse;
access_token?: string;
token_type?: string;
user?: UserResponse;
/** Set when 2FA verification is required before a full token is issued. */
requires_2fa?: boolean;
pre_auth_token?: string;
two_fa_methods?: string[];
}
export interface UserResponse {
@@ -2414,6 +2427,66 @@ export interface SMTPSettings {
smtp_from_name: string;
}
// 2FA / MFA interfaces
export interface TwoFAStatus {
totp_enabled: boolean;
email_otp_enabled: boolean;
backup_codes_remaining: number;
}
export interface TOTPSetupResponse {
secret: string;
qr_code_b64: string;
issuer: string;
}
export interface TOTPEnableResponse {
message: string;
backup_codes: string[];
}
export interface BackupCodesResponse {
backup_codes: string[];
message: string;
}
export interface TwoFAVerifyRequest {
pre_auth_token: string;
code: string;
method: 'totp' | 'email' | 'backup';
}
// OIDC interfaces
export interface OIDCProvider {
id: number;
name: string;
issuer_url: string;
client_id: string;
scopes: string;
is_enabled: boolean;
auto_create_users: boolean;
icon_url?: string | null;
}
export interface OIDCProviderCreate {
name: string;
issuer_url: string;
client_id: string;
client_secret: string;
scopes?: string;
is_enabled?: boolean;
auto_create_users?: boolean;
icon_url?: string | null;
}
export interface OIDCLink {
id: number;
provider_id: number;
provider_name: string;
provider_email?: string | null;
created_at: string;
}
export interface TestSMTPRequest {
test_recipient: string;
}
@@ -2504,12 +2577,106 @@ export const api = {
method: 'POST',
body: JSON.stringify(data),
}),
// H-6: Confirm password reset using the token from the emailed link
forgotPasswordConfirm: (token: string, newPassword: string) =>
request<ForgotPasswordResponse>('/auth/forgot-password/confirm', {
method: 'POST',
body: JSON.stringify({ token, new_password: newPassword }),
}),
resetUserPassword: (data: ResetPasswordRequest) =>
request<ResetPasswordResponse>('/auth/reset-password', {
method: 'POST',
body: JSON.stringify(data),
}),
// 2FA - status
get2FAStatus: () => request<TwoFAStatus>('/auth/2fa/status'),
// 2FA - TOTP
setupTOTP: () => request<TOTPSetupResponse>('/auth/2fa/totp/setup', { method: 'POST' }),
enableTOTP: (code: string) =>
request<TOTPEnableResponse>('/auth/2fa/totp/enable', {
method: 'POST',
body: JSON.stringify({ code }),
}),
disableTOTP: (code: string) =>
request<{ message: string }>('/auth/2fa/totp/disable', {
method: 'POST',
body: JSON.stringify({ code }),
}),
regenerateBackupCodes: (code: string) =>
request<BackupCodesResponse>('/auth/2fa/totp/regenerate-backup-codes', {
method: 'POST',
body: JSON.stringify({ code }),
}),
// 2FA - Email OTP
// Step 1: send a verification code to the user's email (proof of possession)
enableEmailOTP: () =>
request<{ message: string; setup_token: string }>('/auth/2fa/email/enable', { method: 'POST' }),
// Step 2: confirm with the code received by email
confirmEnableEmailOTP: (setup_token: string, code: string) =>
request<{ message: string }>('/auth/2fa/email/enable/confirm', {
method: 'POST',
body: JSON.stringify({ setup_token, code }),
}),
// Disable requires account password for re-auth
disableEmailOTP: (password: string) =>
request<{ message: string }>('/auth/2fa/email/disable', {
method: 'POST',
body: JSON.stringify({ password }),
}),
sendEmailOTP: (preAuthToken: string) =>
request<{ message: string; pre_auth_token?: string }>('/auth/2fa/email/send', {
method: 'POST',
body: JSON.stringify({ pre_auth_token: preAuthToken }),
}),
// 2FA - verify (completes login)
verify2FA: (data: TwoFAVerifyRequest) =>
request<LoginResponse>('/auth/2fa/verify', {
method: 'POST',
body: JSON.stringify(data),
}),
// 2FA - admin
admin2FADisable: (userId: number) =>
request<{ message: string }>(`/auth/2fa/admin/${userId}`, { method: 'DELETE' }),
// OIDC providers (public list)
getOIDCProviders: () => request<OIDCProvider[]>('/auth/oidc/providers'),
// OIDC providers (admin)
getOIDCProvidersAll: () => request<OIDCProvider[]>('/auth/oidc/providers/all'),
createOIDCProvider: (data: OIDCProviderCreate) =>
request<OIDCProvider>('/auth/oidc/providers', {
method: 'POST',
body: JSON.stringify(data),
}),
updateOIDCProvider: (id: number, data: Partial<OIDCProviderCreate>) =>
request<OIDCProvider>(`/auth/oidc/providers/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
deleteOIDCProvider: (id: number) =>
request<{ message: string }>(`/auth/oidc/providers/${id}`, { method: 'DELETE' }),
// OIDC authorize URL
getOIDCAuthorizeUrl: (providerId: number) =>
request<{ auth_url: string }>(`/auth/oidc/authorize/${providerId}`),
// OIDC exchange token for JWT
exchangeOIDCToken: (oidcToken: string) =>
request<LoginResponse>('/auth/oidc/exchange', {
method: 'POST',
body: JSON.stringify({ oidc_token: oidcToken }),
}),
// OIDC links for current user
getOIDCLinks: () => request<OIDCLink[]>('/auth/oidc/links'),
deleteOIDCLink: (providerId: number) =>
request<{ message: string }>(`/auth/oidc/links/${providerId}`, { method: 'DELETE' }),
// Users
getUsers: () => request<UserResponse[]>('/users/'),
getUser: (id: number) => request<UserResponse>(`/users/${id}`),