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
+29 -9
View File
@@ -1,6 +1,6 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { api, getAuthToken, setAuthToken } from '../api/client';
import type { Permission, UserResponse } from '../api/client';
import type { LoginResponse, Permission, UserResponse } from '../api/client';
interface AuthContextType {
user: UserResponse | null;
@@ -8,7 +8,10 @@ interface AuthContextType {
requiresSetup: boolean;
loading: boolean;
isAdmin: boolean;
login: (username: string, password: string) => Promise<void>;
/** Login with username/password. Returns LoginResponse (may include requires_2fa). */
login: (username: string, password: string) => Promise<LoginResponse>;
/** Finalise login after 2FA or OIDC — store token and set user directly. */
loginWithToken: (token: string, user: UserResponse) => void;
logout: () => void;
refreshUser: () => Promise<void>;
refreshAuth: () => Promise<void>;
@@ -30,12 +33,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const checkAuthStatus = async () => {
try {
// Bootstrap: if URL has ?token= param, store it and strip from URL.
// Allows SpoolBuddy kiosk to pass API key via URL on first load.
// Bootstrap: if URL has ?token= param, store it session-only first and
// strip it from the URL. Allows SpoolBuddy kiosk to pass an API key via
// URL on first load. Persistence to localStorage is deferred until the
// token has been verified by the server (L-4: prevents session fixation
// where an attacker-crafted URL immediately persists a forged/stolen token).
const urlParams = new URLSearchParams(window.location.search);
const urlToken = urlParams.get('token');
if (urlToken) {
setAuthToken(urlToken);
setAuthToken(urlToken, false); // session-only until server confirms it's valid
urlParams.delete('token');
const cleanSearch = urlParams.toString();
const cleanUrl = window.location.pathname
@@ -56,8 +62,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const currentUser = await api.getCurrentUser();
if (!mountedRef.current) return;
setUser(currentUser);
// Persist kiosk token only after the server confirms it is valid.
if (urlToken && token === urlToken) {
setAuthToken(urlToken, true);
}
} catch {
// Token invalid, clear it
// Token invalid, clear it (removes from both sessionStorage and localStorage)
setAuthToken(null);
if (!mountedRef.current) return;
setUser(null);
@@ -106,10 +116,19 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
}, [loading, requiresSetup, authEnabled]);
const login = async (username: string, password: string) => {
const login = async (username: string, password: string): Promise<LoginResponse> => {
const response = await api.login({ username, password });
setAuthToken(response.access_token);
await checkAuthStatus();
if (!response.requires_2fa && response.access_token) {
setAuthToken(response.access_token);
await checkAuthStatus();
}
return response;
};
const loginWithToken = (token: string, userObj: UserResponse) => {
setAuthToken(token);
setUser(userObj);
setAuthEnabled(true);
};
const logout = () => {
@@ -205,6 +224,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
loading,
isAdmin,
login,
loginWithToken,
logout,
refreshUser,
refreshAuth,