mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-07 06:31:22 +02:00
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:
+173
-6
@@ -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}`),
|
||||
|
||||
Reference in New Issue
Block a user