feat(security): add TOTP-based multi-factor authentication with backend and UI support (#5417)

# Description of Changes

This pull request introduces several improvements and new features
across the authentication and admin data APIs, with a particular focus
on multi-factor authentication (MFA) support and better handling of user
settings. The changes include integrating MFA status into account data
responses, masking sensitive user settings in admin views, and
refactoring code to use more robust user creation methods. Additionally,
there are minor code cleanups and consistency improvements.

### Multi-factor Authentication (MFA) Integration

* Added `mfaEnabled` and `mfaRequired` fields to the `AccountData`
response, populated using the new `MfaService`, to provide clients with
MFA status information for users.
[[1]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R430-R431)
[[2]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R72)
[[3]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L83-R86)
[[4]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R98)
[[5]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R574-R575)

### User Settings Handling and Security

* Admin settings API now returns user settings for each user, with the
`mfaSecret` field masked to protect sensitive information. This is
achieved by fetching settings via `findByIdWithSettings` and
copying/masking the relevant field before returning.
[[1]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R252-R259)
[[2]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L302-R322)
[[3]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R378)
[[4]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R563)

### Refactoring and Code Consistency

* Refactored user creation in `InitialSecuritySetup` to use the
`SaveUserRequest` builder and `saveUserCore` for better maintainability
and clarity, replacing direct calls to `saveUser`.
[[1]](diffhunk://#diff-0c7960a6283a07c4905ac9785b2820b412574c9f86918ada30caba0356d34850R22)
[[2]](diffhunk://#diff-0c7960a6283a07c4905ac9785b2820b412574c9f86918ada30caba0356d34850L116-R124)
[[3]](diffhunk://#diff-0c7960a6283a07c4905ac9785b2820b412574c9f86918ada30caba0356d34850L130-R144)
[[4]](diffhunk://#diff-0c7960a6283a07c4905ac9785b2820b412574c9f86918ada30caba0356d34850L140-R160)
* Standardized checks for internal team membership by comparing with
`TeamService.INTERNAL_TEAM_NAME` on the left side for consistency.
[[1]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L267-R273)
[[2]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L332-R351)
[[3]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L421-R443)
[[4]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L449-R471)
[[5]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L462-R485)

### API and DTO Changes

* Changed the login API to accept `UsernameAndPassMfa` instead of
`UsernameAndPass`, paving the way for MFA code support in authentication
requests.
[[1]](diffhunk://#diff-9ca4f9246abe79368552264e2e18d7ed039e084c70c0794eb02cfd1b75fbd8a8L30-R41)
[[2]](diffhunk://#diff-9ca4f9246abe79368552264e2e18d7ed039e084c70c0794eb02cfd1b75fbd8a8L61-R71)
* Updated import statements and controller dependencies to include new
DTOs and services related to MFA.
[[1]](diffhunk://#diff-9ca4f9246abe79368552264e2e18d7ed039e084c70c0794eb02cfd1b75fbd8a8L14-R19)
[[2]](diffhunk://#diff-9ca4f9246abe79368552264e2e18d7ed039e084c70c0794eb02cfd1b75fbd8a8R56-R57)

These updates improve security, prepare the system for MFA rollout, and
make admin and authentication APIs more robust and informative.

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
Ludy
2026-01-23 21:34:57 +00:00
committed by GitHub
co-authored by Copilot
parent 0436460c03
commit 0b86dd79d3
52 changed files with 3739 additions and 558 deletions
@@ -15,6 +15,9 @@ interface SelfHostedLoginScreenProps {
enabledOAuthProviders?: SSOProviderConfig[];
onLogin: (username: string, password: string) => Promise<void>;
onOAuthSuccess: (userInfo: UserInfo) => Promise<void>;
mfaCode: string;
setMfaCode: (value: string) => void;
requiresMfa: boolean;
loading: boolean;
error: string | null;
}
@@ -24,6 +27,9 @@ export const SelfHostedLoginScreen: React.FC<SelfHostedLoginScreenProps> = ({
enabledOAuthProviders,
onLogin,
onOAuthSuccess,
mfaCode,
setMfaCode,
requiresMfa,
loading,
error,
}) => {
@@ -44,6 +50,11 @@ export const SelfHostedLoginScreen: React.FC<SelfHostedLoginScreenProps> = ({
return;
}
if (requiresMfa && !mfaCode.trim()) {
setValidationError(t('login.mfaRequired', 'Two-factor code required'));
return;
}
setValidationError(null);
await onLogin(username.trim(), password);
};
@@ -98,6 +109,13 @@ export const SelfHostedLoginScreen: React.FC<SelfHostedLoginScreenProps> = ({
setPassword(value);
setValidationError(null);
}}
mfaCode={mfaCode}
setMfaCode={(value) => {
setMfaCode(value);
setValidationError(null);
}}
showMfaField={requiresMfa || Boolean(mfaCode)}
requiresMfa={requiresMfa}
onSubmit={handleSubmit}
isSubmitting={loading}
submitButtonText={t('setup.login.submit', 'Login')}
@@ -6,7 +6,7 @@ import { SaaSSignupScreen } from '@app/components/SetupWizard/SaaSSignupScreen';
import { ServerSelectionScreen } from '@app/components/SetupWizard/ServerSelectionScreen';
import { SelfHostedLoginScreen } from '@app/components/SetupWizard/SelfHostedLoginScreen';
import { ServerConfig, connectionModeService } from '@app/services/connectionModeService';
import { authService, UserInfo } from '@app/services/authService';
import { AuthServiceError, authService, UserInfo } from '@app/services/authService';
import { tauriBackendService } from '@app/services/tauriBackendService';
import { STIRLING_SAAS_URL } from '@desktop/constants/connection';
import { listen } from '@tauri-apps/api/event';
@@ -30,6 +30,8 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
const [serverConfig, setServerConfig] = useState<ServerConfig | null>({ url: STIRLING_SAAS_URL });
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selfHostedMfaCode, setSelfHostedMfaCode] = useState('');
const [selfHostedMfaRequired, setSelfHostedMfaRequired] = useState(false);
const handleSaaSLogin = async (username: string, password: string) => {
if (!serverConfig) {
@@ -97,6 +99,8 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
const handleServerSelection = (config: ServerConfig) => {
setServerConfig(config);
setError(null);
setSelfHostedMfaCode('');
setSelfHostedMfaRequired(false);
setActiveStep(SetupStep.SelfHostedLogin);
};
@@ -116,9 +120,14 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
setError(null);
console.log('[SetupWizard] Step 1: Authenticating with server...');
await authService.login(serverConfig.url, username, password);
const trimmedMfa = selfHostedMfaCode.trim();
const mfaCode = trimmedMfa ? trimmedMfa : undefined;
await authService.login(serverConfig.url, username, password, mfaCode);
console.log('[SetupWizard] ✅ Authentication successful');
setSelfHostedMfaRequired(false);
setSelfHostedMfaCode('');
console.log('[SetupWizard] Step 2: Switching to self-hosted mode...');
await connectionModeService.switchToSelfHosted(serverConfig);
console.log('[SetupWizard] ✅ Switched to self-hosted mode');
@@ -131,7 +140,20 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
onComplete();
} catch (err) {
console.error('[SetupWizard] ❌ Self-hosted login failed:', err);
const errorMessage = err instanceof Error ? err.message : 'Self-hosted login failed';
let errorMessage = 'Self-hosted login failed';
if (err instanceof AuthServiceError) {
if (err.code === 'mfa_required' || err.code === 'invalid_mfa_code') {
setSelfHostedMfaRequired(true);
}
errorMessage = err.message;
} else if (err instanceof Error) {
errorMessage = err.message;
} else if (typeof err === 'string') {
errorMessage = err;
}
if (errorMessage.toLowerCase().includes('mfa_required') || errorMessage.toLowerCase().includes('invalid_mfa_code')) {
setSelfHostedMfaRequired(true);
}
console.error('[SetupWizard] Error message:', errorMessage);
setError(errorMessage);
setLoading(false);
@@ -239,6 +261,8 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
const handleBack = () => {
setError(null);
if (activeStep === SetupStep.SelfHostedLogin) {
setSelfHostedMfaCode('');
setSelfHostedMfaRequired(false);
setActiveStep(SetupStep.ServerSelection);
} else if (activeStep === SetupStep.ServerSelection) {
setActiveStep(SetupStep.SaaSLogin);
@@ -286,6 +310,9 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
enabledOAuthProviders={serverConfig?.enabledOAuthProviders}
onLogin={handleSelfHostedLogin}
onOAuthSuccess={handleSelfHostedOAuthSuccess}
mfaCode={selfHostedMfaCode}
setMfaCode={setSelfHostedMfaCode}
requiresMfa={selfHostedMfaRequired}
loading={loading}
error={error}
/>
@@ -101,6 +101,9 @@ export function setupApiInterceptors(client: AxiosInstance): void {
// Handle 401 Unauthorized - try to refresh token
if (error.response?.status === 401 && !originalRequest._retry) {
if (originalRequest.skipAuthRedirect) {
return Promise.reject(error);
}
originalRequest._retry = true;
const isRemote = await operationRouter.isSelfHostedMode();
+33 -12
View File
@@ -11,6 +11,15 @@ export interface UserInfo {
email?: string;
}
export class AuthServiceError extends Error {
code?: string;
constructor(message: string, code?: string) {
super(message);
this.code = code;
}
}
interface LoginResponse {
token: string;
username: string;
@@ -82,7 +91,6 @@ export class AuthService {
// Try Tauri store first
try {
const token = await invoke<string | null>('get_auth_token');
if (token) {
console.log(`[Desktop AuthService] ✅ Token found in Tauri store (length: ${token.length})`);
return token;
@@ -222,7 +230,7 @@ export class AuthService {
}
}
async login(serverUrl: string, username: string, password: string): Promise<UserInfo> {
async login(serverUrl: string, username: string, password: string, mfaCode?: string): Promise<UserInfo> {
console.log(`[Desktop AuthService] 🔐 Starting login to: ${serverUrl}`);
console.log(`[Desktop AuthService] Username: ${username}`);
@@ -246,6 +254,7 @@ export class AuthService {
serverUrl,
username,
password,
mfaCode,
supabaseKey: SUPABASE_KEY,
saasServerUrl: STIRLING_SAAS_URL,
});
@@ -286,8 +295,21 @@ export class AuthService {
console.error('[Desktop AuthService] ❌ Login failed:', error);
// Provide more detailed error messages based on the error type
if (error instanceof Error) {
const errMsg = error.message.toLowerCase();
if (error instanceof Error || typeof error === 'string') {
const rawMessage = typeof error === 'string' ? error : error.message;
const errMsg = rawMessage.toLowerCase();
if (errMsg.includes('mfa_required')) {
this.setAuthStatus('unauthenticated', null);
console.error('[Desktop AuthService] Two-factor authentication required');
throw new AuthServiceError('Two-factor code required.', 'mfa_required');
}
if (errMsg.includes('invalid_mfa_code')) {
this.setAuthStatus('unauthenticated', null);
console.error('[Desktop AuthService] Invalid two-factor code provided');
throw new AuthServiceError('Invalid two-factor code.', 'invalid_mfa_code');
}
// Authentication errors
if (errMsg.includes('401') || errMsg.includes('unauthorized') || errMsg.includes('invalid credentials')) {
@@ -412,7 +434,6 @@ export class AuthService {
this.cachedToken = token;
console.log('[Desktop AuthService] ✅ Token cached in memory after retrieval');
}
return token;
} catch (error) {
console.error('[Desktop AuthService] Failed to get auth token:', error);
@@ -449,7 +470,7 @@ export class AuthService {
async refreshToken(serverUrl: string): Promise<boolean> {
try {
console.log('Refreshing auth token');
console.log('[Desktop AuthService] Refreshing auth token');
this.setAuthStatus('refreshing', this.userInfo);
const currentToken = await this.getAuthToken();
@@ -477,10 +498,10 @@ export class AuthService {
const userInfo = await this.getUserInfo();
this.setAuthStatus('authenticated', userInfo);
console.log('Token refreshed successfully');
console.log('[Desktop AuthService] Token refreshed successfully');
return true;
} catch (error) {
console.error('Token refresh failed:', error);
console.error('[Desktop AuthService] Token refresh failed:', error);
this.setAuthStatus('unauthenticated', null);
// Clear stored credentials on refresh failure
@@ -533,7 +554,7 @@ export class AuthService {
*/
async loginWithOAuth(provider: string, authServerUrl: string, successHtml: string, errorHtml: string): Promise<UserInfo> {
try {
console.log('Starting OAuth login with provider:', provider);
console.log('[Desktop AuthService] Starting OAuth login with provider:', provider);
this.setAuthStatus('oauth_pending', null);
// Validate Supabase key is configured for OAuth
@@ -554,7 +575,7 @@ export class AuthService {
errorHtml,
});
console.log('OAuth authentication successful, storing tokens');
console.log('[Desktop AuthService] OAuth authentication successful, storing tokens');
// Save token to all storage locations
await this.saveTokenEverywhere(result.access_token);
@@ -569,11 +590,11 @@ export class AuthService {
});
this.setAuthStatus('authenticated', userInfo);
console.log('OAuth login successful');
console.log('[Desktop AuthService] OAuth login successful');
return userInfo;
} catch (error) {
console.error('Failed to complete OAuth login:', error);
console.error('[Desktop AuthService] Failed to complete OAuth login:', error);
this.setAuthStatus('unauthenticated', null);
throw error;
}
@@ -26,6 +26,7 @@ export interface TauriHttpRequestConfig {
// Custom properties for desktop
operationName?: string;
skipBackendReadyCheck?: boolean;
skipAuthRedirect?: boolean;
// Axios compatibility properties (ignored by Tauri HTTP)
suppressErrorToast?: boolean;
cancelToken?: any;