From 9ef25d9c68f9bd3dc150af867072fcdcb80f10c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Cohen=20Arazi?= Date: Wed, 24 Jun 2026 14:10:05 -0300 Subject: [PATCH 1/3] [#5864] Add NgRx state management and MFA HTTP service Companion to DSpace/DSpace#12703 (backend MFA implementation). This patch adds the core state management infrastructure for the two-step MFA login flow in the Angular frontend. Architecture decisions: 1. NgRx for MFA state (not component-local state): The MFA flow spans multiple components (login form -> MFA input) and requires coordination with the existing auth effects pipeline. NgRx ensures the MFA-pending token is preserved across component boundaries and the flow can be interrupted/resumed cleanly. 2. Separate mfa.actions.ts / mfa.effects.ts (not merged into auth.*): Keeps MFA logic isolated and removable. The auth.reducer.ts is extended with MFA fields since the state must be in the same slice for selector composition. 3. MfaService uses HttpClient directly (not DSpace HAL client): The MFA endpoints return plain JSON (not HAL+JSON), so the complex HAL parsing infrastructure is unnecessary. The service explicitly attaches the Bearer token from AuthService.getToken() since the global AuthInterceptor may not have the token available during the MFA-pending state. 4. JWT claim inspection in authenticateSuccess$: The effect decodes the JWT payload (base64) to check mfa_verified. This is safe because we only read a boolean - the signature is validated server-side. This avoids an extra round-trip to /status. 5. MFA verify uses observe: response to capture Authorization header: After successful TOTP verification, the backend issues a new JWT in the response Authorization header. The effect extracts it and dispatches AuthenticationSuccessAction to continue normal login. State shape additions to AuthState: mfaRequired: boolean - true when TOTP input should be shown mfaPendingToken: AuthTokenInfo - the mfa_verified=false token mfaError: string - verification error message mfaVerifying: boolean - loading state during verify call New selectors: isMfaRequired, isMfaVerifying, getMfaError, getMfaPendingToken Test plan: 1. Apply patch 2. Build: npm run build => SUCCESS: No compilation errors 3. Sign off :-D --- src/app/core/auth/auth.effects.ts | 17 ++- src/app/core/auth/auth.reducer.ts | 61 +++++++++- src/app/core/auth/mfa.actions.ts | 98 ++++++++++++++++ src/app/core/auth/mfa.effects.ts | 104 +++++++++++++++++ src/app/core/auth/mfa.service.ts | 179 ++++++++++++++++++++++++++++++ src/app/core/auth/selectors.ts | 11 ++ src/app/core/core.effects.ts | 2 + 7 files changed, 470 insertions(+), 2 deletions(-) create mode 100644 src/app/core/auth/mfa.actions.ts create mode 100644 src/app/core/auth/mfa.effects.ts create mode 100644 src/app/core/auth/mfa.service.ts diff --git a/src/app/core/auth/auth.effects.ts b/src/app/core/auth/auth.effects.ts index 7c2a494cff5..bb76fd4ebc3 100644 --- a/src/app/core/auth/auth.effects.ts +++ b/src/app/core/auth/auth.effects.ts @@ -74,6 +74,7 @@ import { } from './auth.actions'; // import services import { AuthService } from './auth.service'; +import { MfaRequiredAction } from './mfa.actions'; import { AuthMethod } from './models/auth.method'; import { AuthStatus } from './models/auth-status.model'; import { AuthTokenInfo } from './models/auth-token-info.model'; @@ -117,7 +118,21 @@ export class AuthEffects { public authenticateSuccess$: Observable = createEffect(() => this.actions$.pipe( ofType(AuthActionTypes.AUTHENTICATE_SUCCESS), - map((action: AuthenticationSuccessAction) => new AuthenticatedAction(action.payload)), + map((action: AuthenticationSuccessAction) => { + // Check if the token has mfa_verified=false, meaning MFA verification is needed + const token = action.payload; + if (token && token.accessToken) { + try { + const payload = JSON.parse(atob(token.accessToken.split('.')[1])); + if (payload.mfa_verified === false) { + return new MfaRequiredAction(token); + } + } catch (e) { + // If token parsing fails, proceed with normal flow + } + } + return new AuthenticatedAction(action.payload); + }), )); public authenticated$: Observable = createEffect(() => this.actions$.pipe( diff --git a/src/app/core/auth/auth.reducer.ts b/src/app/core/auth/auth.reducer.ts index 25dda850326..0b9e83cc9ff 100644 --- a/src/app/core/auth/auth.reducer.ts +++ b/src/app/core/auth/auth.reducer.ts @@ -15,6 +15,12 @@ import { SetAuthCookieStatus, SetRedirectUrlAction, } from './auth.actions'; +import { + MfaActions, + MfaActionTypes, + MfaRequiredAction, + MfaVerifyErrorAction, +} from './mfa.actions'; import { AuthMethod } from './models/auth.method'; import { AuthMethodType } from './models/auth.method-type'; // import models @@ -65,6 +71,12 @@ export interface AuthState { // true when the current user is idle idle: boolean; + // MFA state + mfaRequired: boolean; + mfaPendingToken?: AuthTokenInfo; + mfaError?: string; + mfaVerifying: boolean; + } /** @@ -78,6 +90,8 @@ const initialState: AuthState = { authMethods: [], externalAuth: false, idle: false, + mfaRequired: false, + mfaVerifying: false, }; /** @@ -86,7 +100,7 @@ const initialState: AuthState = { * @param {State} state Current state * @param {AuthActions} action Incoming action */ -export function authReducer(state: any = initialState, action: AuthActions): AuthState { +export function authReducer(state: any = initialState, action: AuthActions | MfaActions): AuthState { switch (action.type) { case AuthActionTypes.AUTHENTICATE: @@ -150,6 +164,13 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut }); case AuthActionTypes.AUTHENTICATE_SUCCESS: + return Object.assign({}, state, { + mfaRequired: false, + mfaPendingToken: undefined, + mfaError: undefined, + mfaVerifying: false, + }); + case AuthActionTypes.LOG_OUT: return state; @@ -266,6 +287,44 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut blocking: true, }); + case MfaActionTypes.MFA_REQUIRED: + return Object.assign({}, state, { + mfaRequired: true, + mfaPendingToken: (action as MfaRequiredAction).payload, + mfaError: undefined, + mfaVerifying: false, + loading: false, + blocking: false, + }); + + case MfaActionTypes.MFA_VERIFY: + return Object.assign({}, state, { + mfaVerifying: true, + mfaError: undefined, + }); + + case MfaActionTypes.MFA_VERIFY_SUCCESS: + return Object.assign({}, state, { + mfaRequired: false, + mfaPendingToken: undefined, + mfaError: undefined, + mfaVerifying: false, + }); + + case MfaActionTypes.MFA_VERIFY_ERROR: + return Object.assign({}, state, { + mfaVerifying: false, + mfaError: (action as MfaVerifyErrorAction).payload, + }); + + case MfaActionTypes.MFA_RESET: + return Object.assign({}, state, { + mfaRequired: false, + mfaPendingToken: undefined, + mfaError: undefined, + mfaVerifying: false, + }); + default: return state; } diff --git a/src/app/core/auth/mfa.actions.ts b/src/app/core/auth/mfa.actions.ts new file mode 100644 index 00000000000..20902624ac5 --- /dev/null +++ b/src/app/core/auth/mfa.actions.ts @@ -0,0 +1,98 @@ +/* eslint-disable max-classes-per-file */ +import { Action } from '@ngrx/store'; + +import { type } from '../ngrx/type'; +import { AuthTokenInfo } from './models/auth-token-info.model'; + +/** + * NgRx action type constants for the Multi-Factor Authentication flow. + * + * These types follow the DSpace convention of `dspace/auth/ACTION_NAME` + * and are used to identify MFA-related actions in the store. + */ +export const MfaActionTypes = { + MFA_REQUIRED: type('dspace/auth/MFA_REQUIRED'), + MFA_VERIFY: type('dspace/auth/MFA_VERIFY'), + MFA_VERIFY_SUCCESS: type('dspace/auth/MFA_VERIFY_SUCCESS'), + MFA_VERIFY_ERROR: type('dspace/auth/MFA_VERIFY_ERROR'), + MFA_RESET: type('dspace/auth/MFA_RESET'), +}; + +/** + * Dispatched when login succeeds but MFA verification is required. + * + * This action transitions the authentication state machine into the + * "MFA pending" state, where the user must provide a TOTP or recovery code. + */ +export class MfaRequiredAction implements Action { + /** @inheritdoc */ + public type: string = MfaActionTypes.MFA_REQUIRED; + + /** + * @param payload - The MFA-pending authentication token. This token has limited + * privileges and can only be used to call the MFA verify endpoint. + */ + constructor(public payload: AuthTokenInfo) {} +} + +/** + * Dispatched when user submits a TOTP code or recovery code for MFA verification. + * + * Exactly one of `code` or `recoveryCode` should be provided in the payload. + */ +export class MfaVerifyAction implements Action { + /** @inheritdoc */ + public type: string = MfaActionTypes.MFA_VERIFY; + + /** + * @param payload - Object containing either a 6-digit TOTP `code` or a `recoveryCode`. + */ + constructor(public payload: { code?: string; recoveryCode?: string }) {} +} + +/** + * Dispatched when MFA verification succeeds. Contains the new fully-verified token. + * + * After this action, the normal `AuthenticationSuccessAction` flow takes over + * to complete the login process. + */ +export class MfaVerifySuccessAction implements Action { + /** @inheritdoc */ + public type: string = MfaActionTypes.MFA_VERIFY_SUCCESS; + + /** + * @param payload - The fully-authenticated token issued after successful MFA verification, + * or null if the token is extracted from a response header instead. + */ + constructor(public payload: AuthTokenInfo) {} +} + +/** + * Dispatched when MFA verification fails (invalid code, expired code, etc.). + * + * The error message can be used to display feedback to the user. + */ +export class MfaVerifyErrorAction implements Action { + /** @inheritdoc */ + public type: string = MfaActionTypes.MFA_VERIFY_ERROR; + + /** + * @param payload - An error message key or description explaining why verification failed. + */ + constructor(public payload: string) {} +} + +/** + * Dispatched to clear all MFA-related state (e.g., on logout or user cancellation). + * + * This resets the store to its initial state with no pending MFA token or error. + */ +export class MfaResetAction implements Action { + /** @inheritdoc */ + public type: string = MfaActionTypes.MFA_RESET; +} + +/** + * Union type of all MFA-related actions for use in reducers and effects. + */ +export type MfaActions = MfaRequiredAction | MfaVerifyAction | MfaVerifySuccessAction | MfaVerifyErrorAction | MfaResetAction; diff --git a/src/app/core/auth/mfa.effects.ts b/src/app/core/auth/mfa.effects.ts new file mode 100644 index 00000000000..d765e042694 --- /dev/null +++ b/src/app/core/auth/mfa.effects.ts @@ -0,0 +1,104 @@ +import { HttpResponse } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { + Actions, + createEffect, + ofType, +} from '@ngrx/effects'; +import { + select, + Store, +} from '@ngrx/store'; +import { of } from 'rxjs'; +import { + catchError, + map, + switchMap, + withLatestFrom, +} from 'rxjs/operators'; + +import { AuthenticationSuccessAction } from './auth.actions'; +import { AuthService } from './auth.service'; +import { + MfaActionTypes, + MfaVerifyAction, + MfaVerifyErrorAction, + MfaVerifySuccessAction, +} from './mfa.actions'; +import { MfaService } from './mfa.service'; +import { AuthTokenInfo } from './models/auth-token-info.model'; +import { getMfaPendingToken } from './selectors'; + +/** + * NgRx Effects for the Multi-Factor Authentication flow. + * + * Handles side effects triggered by MFA actions, primarily the verification + * of TOTP codes against the backend MFA endpoint. The flow is: + * + * 1. User submits a code -> `MFA_VERIFY` dispatched + * 2. This effect calls the MFA verify endpoint with the pending token + * 3. On success, dispatches `AuthenticationSuccessAction` with the new full-access token + * 4. On failure, dispatches `MfaVerifyErrorAction` with an error message + */ +@Injectable() +export class MfaEffects { + /** + * @param actions$ - NgRx actions stream + * @param mfaService - Service for MFA HTTP calls + * @param authService - Core authentication service + * @param store - NgRx store for accessing pending MFA token state + */ + constructor( + private actions$: Actions, + private mfaService: MfaService, + private authService: AuthService, + private store: Store, + ) {} + + /** + * Effect that handles MFA code verification. + * + * When `MFA_VERIFY` is dispatched, this effect: + * 1. Retrieves the pending MFA token from the store + * 2. POSTs the TOTP/recovery code to the MFA verify endpoint + * 3. Extracts the new fully-verified JWT from the response `Authorization` header + * 4. Dispatches `AuthenticationSuccessAction` on success or `MfaVerifyErrorAction` on failure + */ + public verify$ = createEffect(() => this.actions$.pipe( + ofType(MfaActionTypes.MFA_VERIFY), + withLatestFrom(this.store.pipe(select(getMfaPendingToken))), + switchMap(([action, pendingToken]: [MfaVerifyAction, AuthTokenInfo]) => + this.mfaService.verify(action.payload.code, action.payload.recoveryCode, pendingToken?.accessToken).pipe( + map((response: HttpResponse) => { + const authHeader = response.headers?.get('Authorization') || response.headers?.get('authorization'); + if (authHeader) { + const tokenStr = authHeader.replace('Bearer ', ''); + const newToken = new AuthTokenInfo(tokenStr); + return new AuthenticationSuccessAction(newToken); + } + return new MfaVerifySuccessAction(null); + }), + catchError((error: unknown) => { + const message = error?.error?.error || 'mfa.verify.error'; + return of(new MfaVerifyErrorAction(message)); + }), + ), + ), + )); + + /** + * Effect that handles post-MFA-verification logic. + * + * After `MFA_VERIFY_SUCCESS` is dispatched (fallback path when the token + * is not in the Authorization header), this triggers a re-check of the + * authentication token cookie to complete the login flow. + */ + public verifySuccess$ = createEffect(() => this.actions$.pipe( + ofType(MfaActionTypes.MFA_VERIFY_SUCCESS), + map(() => { + // Trigger re-check of authentication which will pick up the new token + // from the Authorization response header that was set by the verify endpoint. + return { type: 'dspace/auth/CHECK_AUTHENTICATION_TOKEN_COOKIE' }; + }), + )); +} diff --git a/src/app/core/auth/mfa.service.ts b/src/app/core/auth/mfa.service.ts new file mode 100644 index 00000000000..3b4592d8d03 --- /dev/null +++ b/src/app/core/auth/mfa.service.ts @@ -0,0 +1,179 @@ +import { + HttpClient, + HttpHeaders, + HttpResponse, +} from '@angular/common/http'; +import { + inject, + Injectable, +} from '@angular/core'; +import { + APP_CONFIG, + AppConfig, +} from '@dspace/config/app-config.interface'; +import { Observable } from 'rxjs'; + +import { AuthService } from './auth.service'; + +/** + * Response returned when initiating MFA setup. + * Contains the TOTP secret and a provisioning URI for QR code generation. + */ +export interface MfaSetupResponse { + /** The base32-encoded TOTP secret key for manual entry. */ + secret: string; + /** The `otpauth://` URI used to generate a QR code for authenticator apps. */ + provisioningUri: string; +} + +/** + * Response returned after successfully verifying TOTP setup. + * Contains one-time recovery codes the user must store securely. + */ +export interface MfaVerifySetupResponse { + /** List of one-time recovery codes for account access if the authenticator is unavailable. */ + recoveryCodes: string[]; +} + +/** + * Response describing the current MFA status for the authenticated user. + */ +export interface MfaStatusResponse { + /** Whether MFA is currently enabled for the user's account. */ + enabled: boolean; + /** Number of unused recovery codes remaining. */ + remainingRecoveryCodes: number; +} + +/** + * Response returned from the MFA verify endpoint during login. + */ +export interface MfaVerifyResponse { + /** Status string indicating the result of verification (e.g., "success"). */ + status: string; +} + +/** + * Service for Multi-Factor Authentication HTTP operations. + * + * Provides methods for the full MFA lifecycle: + * - Checking MFA status + * - Setting up TOTP (generating secret + QR code) + * - Verifying TOTP codes (both during setup and login) + * - Disabling MFA + * - Regenerating recovery codes + * + * All endpoints communicate with the DSpace REST API at `/api/authn/mfa/*`. + */ +@Injectable({ providedIn: 'root' }) +export class MfaService { + /** Application configuration injected via DI token. */ + private readonly appConfig: AppConfig = inject(APP_CONFIG); + + /** + * @param http - Angular HTTP client for making requests + * @param authService - Authentication service used to retrieve the current token + */ + constructor( + private http: HttpClient, + private authService: AuthService, + ) {} + + /** + * Base URL for all MFA API endpoints. + * @returns The fully-qualified URL to the MFA REST resource + */ + private get baseUrl(): string { + return `${this.appConfig.rest.baseUrl}/api/authn/mfa`; + } + + /** + * Constructs authorization headers using the current user's access token. + * @returns An object containing HttpHeaders with the Bearer token set + */ + private get authHeaders(): { headers: HttpHeaders } { + const token = this.authService.getToken(); + let headers = new HttpHeaders(); + if (token) { + headers = headers.set('Authorization', `Bearer ${token.accessToken}`); + } + return { headers }; + } + + /** + * Retrieves the current MFA status for the authenticated user. + * @returns Observable emitting the MFA status (enabled state and remaining recovery codes) + */ + getStatus(): Observable { + return this.http.get(`${this.baseUrl}/status`, this.authHeaders); + } + + /** + * Initiates MFA setup by requesting a new TOTP secret from the server. + * @returns Observable emitting the setup response with secret and provisioning URI + */ + setup(): Observable { + return this.http.post(`${this.baseUrl}/setup`, {}, this.authHeaders); + } + + /** + * Confirms MFA setup by verifying the user can generate a valid TOTP code. + * On success, MFA becomes active and recovery codes are returned. + * + * @param code - The 6-digit TOTP code from the user's authenticator app + * @returns Observable emitting recovery codes that the user should save + */ + verifySetup(code: string): Observable { + return this.http.post(`${this.baseUrl}/verify-setup`, { code }, this.authHeaders); + } + + /** + * Verifies an MFA code during the login flow. + * Uses the MFA-pending token (not the normal auth token) for authorization. + * + * The full HTTP response is returned so callers can extract the new + * fully-verified JWT from the `Authorization` header. + * + * @param code - The 6-digit TOTP code (mutually exclusive with recoveryCode) + * @param recoveryCode - A one-time recovery code (mutually exclusive with code) + * @param pendingToken - The MFA-pending JWT issued after password authentication + * @returns Observable emitting the full HTTP response including headers + */ + verify(code?: string, recoveryCode?: string, pendingToken?: string): Observable> { + const body: any = {}; + if (code) { + body.code = code; + } + if (recoveryCode) { + body.recoveryCode = recoveryCode; + } + let headers = new HttpHeaders(); + if (pendingToken) { + headers = headers.set('Authorization', `Bearer ${pendingToken}`); + } + return this.http.post(`${this.baseUrl}/verify`, body, { headers, observe: 'response' }); + } + + /** + * Disables MFA for the authenticated user. + * Requires a valid TOTP code to confirm the user has access to their authenticator. + * + * @param code - The 6-digit TOTP code confirming the user's identity + * @returns Observable that completes when MFA is successfully disabled + */ + disable(code: string): Observable { + return this.http.post(`${this.baseUrl}/disable`, { code }, this.authHeaders); + } + + /** + * Regenerates recovery codes for the authenticated user. + * Invalidates all previously issued recovery codes. + * Requires a valid TOTP code to confirm the user's identity. + * + * @param code - The 6-digit TOTP code confirming the user's identity + * @returns Observable emitting the new set of recovery codes + */ + regenerateRecoveryCodes(code: string): Observable { + return this.http.post(`${this.baseUrl}/recovery-codes`, { code }, this.authHeaders); + } +} diff --git a/src/app/core/auth/selectors.ts b/src/app/core/auth/selectors.ts index 63603776263..718cd34b3bf 100644 --- a/src/app/core/auth/selectors.ts +++ b/src/app/core/auth/selectors.ts @@ -261,3 +261,14 @@ export const getRedirectUrl = createSelector(getAuthState, _getRedirectUrl); * @return {boolean} */ export const isIdle = createSelector(getAuthState, _isIdle); + +// MFA Selectors +const _isMfaRequired = (state: AuthState) => state.mfaRequired; +const _isMfaVerifying = (state: AuthState) => state.mfaVerifying; +const _getMfaError = (state: AuthState) => state.mfaError; +const _getMfaPendingToken = (state: AuthState) => state.mfaPendingToken; + +export const isMfaRequired = createSelector(getAuthState, _isMfaRequired); +export const isMfaVerifying = createSelector(getAuthState, _isMfaVerifying); +export const getMfaError = createSelector(getAuthState, _getMfaError); +export const getMfaPendingToken = createSelector(getAuthState, _getMfaPendingToken); diff --git a/src/app/core/core.effects.ts b/src/app/core/core.effects.ts index 790c141f872..5cc92f74cf4 100644 --- a/src/app/core/core.effects.ts +++ b/src/app/core/core.effects.ts @@ -1,4 +1,5 @@ import { AuthEffects } from './auth/auth.effects'; +import { MfaEffects } from './auth/mfa.effects'; import { ObjectCacheEffects } from './cache/object-cache.effects'; import { ServerSyncBufferEffects } from './cache/server-sync-buffer.effects'; import { ObjectUpdatesEffects } from './data/object-updates/object-updates.effects'; @@ -14,6 +15,7 @@ export const coreEffects = [ ObjectCacheEffects, UUIDIndexEffects, AuthEffects, + MfaEffects, JsonPatchOperationsEffects, ServerSyncBufferEffects, ObjectUpdatesEffects, From 20d56d06cfed77d253e5815c38f30ba2f81d1c0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Cohen=20Arazi?= Date: Wed, 24 Jun 2026 14:10:26 -0300 Subject: [PATCH 2/3] [#5864] Add two-step login flow with TOTP verification UI This patch modifies the login page to support MFA verification as a second step after successful password authentication. UX flow: 1. User enters email + password -> AuthenticateAction 2. Backend returns JWT with mfa_verified=false 3. authenticateSuccess$ detects claim -> MfaRequiredAction 4. Login form hides, LogInMfaComponent appears 5. User enters 6-digit TOTP code (or recovery code) 6. MfaVerifyAction -> POST /api/authn/mfa/verify with pending token 7. Backend returns new JWT with mfa_verified=true in response header 8. AuthenticationSuccessAction dispatched -> normal auth flow resumes Design decisions: 1. MFA step shown WITHIN the login page (not a separate route): Prevents URL-based state leakage and keeps the flow atomic. If the user navigates away, the MFA state resets (MfaResetAction). 2. LogInMfaComponent is standalone (not a login "method"): MFA is not an authentication METHOD (like Shibboleth/OIDC) - it is a second FACTOR within the password method. It renders conditionally based on mfaRequired selector, replacing the method list. 3. Pending token passed explicitly in Authorization header: During the MFA step, the token is NOT stored in the auth cookie (because the normal auth flow was intercepted). The verify effect reads mfaPendingToken from the store and passes it directly. 4. Recovery code toggle: Same input field with relaxed validation (any string vs 6-digit pattern). Dispatches MfaVerifyAction with recoveryCode field. 5. Uses dsBtnDisabled directive (DSpace convention): Buttons use [dsBtnDisabled] instead of [disabled] per project lint rules, ensuring consistent accessible disabled-state styling. 6. Custom theme compatibility: The custom theme's LogInComponent imports are updated to include LogInMfaComponent since it inherits the base template. Test plan: 1. Apply patch (requires running backend with MFA-enrolled user) 2. Navigate to /login, submit valid credentials => SUCCESS: TOTP code input appears 3. Enter valid 6-digit code from authenticator app => SUCCESS: Login completes, redirected to homepage 4. Click "Use a recovery code instead", enter recovery code => SUCCESS: Login completes 5. Click "Cancel" => SUCCESS: Returns to password form 6. Sign off :-D --- src/app/shared/log-in/log-in.component.html | 7 +- src/app/shared/log-in/log-in.component.ts | 11 ++ .../methods/mfa/log-in-mfa.component.html | 63 +++++++++ .../methods/mfa/log-in-mfa.component.scss | 3 + .../methods/mfa/log-in-mfa.component.spec.ts | 111 +++++++++++++++ .../methods/mfa/log-in-mfa.component.ts | 129 ++++++++++++++++++ .../app/shared/log-in/log-in.component.ts | 2 + 7 files changed, 325 insertions(+), 1 deletion(-) create mode 100644 src/app/shared/log-in/methods/mfa/log-in-mfa.component.html create mode 100644 src/app/shared/log-in/methods/mfa/log-in-mfa.component.scss create mode 100644 src/app/shared/log-in/methods/mfa/log-in-mfa.component.spec.ts create mode 100644 src/app/shared/log-in/methods/mfa/log-in-mfa.component.ts diff --git a/src/app/shared/log-in/log-in.component.html b/src/app/shared/log-in/log-in.component.html index 8fdd91b4faf..f46ebd49146 100644 --- a/src/app/shared/log-in/log-in.component.html +++ b/src/app/shared/log-in/log-in.component.html @@ -1,7 +1,12 @@ @if ((loading | async) || (isAuthenticated | async)) { } -@if ((loading | async) !== true && (isAuthenticated | async) !== true) { +@if (mfaRequired | async) { + +} +@if ((loading | async) !== true && (isAuthenticated | async) !== true && (mfaRequired | async) !== true) { } +
+
{{'profile.card.mfa' | translate}}
+
+ +
+
diff --git a/src/app/profile-page/profile-page.component.spec.ts b/src/app/profile-page/profile-page.component.spec.ts index 7ac2409cbc1..01614059ddf 100644 --- a/src/app/profile-page/profile-page.component.spec.ts +++ b/src/app/profile-page/profile-page.component.spec.ts @@ -47,6 +47,7 @@ import { PaginationComponent } from '../shared/pagination/pagination.component'; import { VarDirective } from '../shared/utils/var.directive'; import { ProfilePageComponent } from './profile-page.component'; import { ThemedProfilePageMetadataFormComponent } from './profile-page-metadata-form/themed-profile-page-metadata-form.component'; +import { ProfilePageMfaFormComponent } from './profile-page-mfa-form/profile-page-mfa-form.component'; import { ProfilePageResearcherFormComponent } from './profile-page-researcher-form/profile-page-researcher-form.component'; import { ProfilePageSecurityFormComponent } from './profile-page-security-form/profile-page-security-form.component'; @@ -138,6 +139,7 @@ describe('ProfilePageComponent', () => { imports: [ ThemedProfilePageMetadataFormComponent, ProfilePageSecurityFormComponent, + ProfilePageMfaFormComponent, ProfilePageResearcherFormComponent, SuggestionsNotificationComponent, NgTemplateOutlet, diff --git a/src/app/profile-page/profile-page.component.ts b/src/app/profile-page/profile-page.component.ts index 38013a0f5fb..c8dfaf233ae 100644 --- a/src/app/profile-page/profile-page.component.ts +++ b/src/app/profile-page/profile-page.component.ts @@ -55,6 +55,7 @@ import { ThemedLoadingComponent } from '../shared/loading/themed-loading.compone import { PaginationComponent } from '../shared/pagination/pagination.component'; import { VarDirective } from '../shared/utils/var.directive'; import { ThemedProfilePageMetadataFormComponent } from './profile-page-metadata-form/themed-profile-page-metadata-form.component'; +import { ProfilePageMfaFormComponent } from './profile-page-mfa-form/profile-page-mfa-form.component'; import { ProfilePageResearcherFormComponent } from './profile-page-researcher-form/profile-page-researcher-form.component'; import { ProfilePageSecurityFormComponent } from './profile-page-security-form/profile-page-security-form.component'; @@ -68,6 +69,7 @@ import { ProfilePageSecurityFormComponent } from './profile-page-security-form/p ErrorComponent, NgTemplateOutlet, PaginationComponent, + ProfilePageMfaFormComponent, ProfilePageResearcherFormComponent, ProfilePageSecurityFormComponent, RouterModule, diff --git a/src/app/shared/log-in/methods/mfa/log-in-mfa.component.spec.ts b/src/app/shared/log-in/methods/mfa/log-in-mfa.component.spec.ts index 677c176d641..cbb5bdf8e1d 100644 --- a/src/app/shared/log-in/methods/mfa/log-in-mfa.component.spec.ts +++ b/src/app/shared/log-in/methods/mfa/log-in-mfa.component.spec.ts @@ -3,10 +3,7 @@ import { TestBed, } from '@angular/core/testing'; import { ReactiveFormsModule } from '@angular/forms'; -import { - MfaResetAction, - MfaVerifyAction, -} from '@dspace/core/auth/mfa.actions'; +import { MfaActionTypes } from '@dspace/core/auth/mfa.actions'; import { getMfaError, isMfaVerifying, @@ -78,7 +75,7 @@ describe('LogInMfaComponent', () => { spyOn(store, 'dispatch'); component.form.get('code').setValue('123456'); component.submit(); - expect(store.dispatch).toHaveBeenCalledWith(new MfaVerifyAction({ code: '123456' })); + expect(store.dispatch).toHaveBeenCalledWith(jasmine.objectContaining({ type: MfaActionTypes.MFA_VERIFY, payload: { code: '123456' } })); }); it('should dispatch MfaVerifyAction with recoveryCode when in recovery mode', () => { @@ -86,13 +83,13 @@ describe('LogInMfaComponent', () => { component.toggleRecovery(); component.form.get('code').setValue('abc12345'); component.submit(); - expect(store.dispatch).toHaveBeenCalledWith(new MfaVerifyAction({ recoveryCode: 'abc12345' })); + expect(store.dispatch).toHaveBeenCalledWith(jasmine.objectContaining({ type: MfaActionTypes.MFA_VERIFY, payload: { recoveryCode: 'abc12345' } })); }); it('should dispatch MfaResetAction on cancel', () => { spyOn(store, 'dispatch'); component.cancel(); - expect(store.dispatch).toHaveBeenCalledWith(new MfaResetAction()); + expect(store.dispatch).toHaveBeenCalledWith(jasmine.objectContaining({ type: MfaActionTypes.MFA_RESET })); }); it('should toggle between TOTP and recovery input', () => { diff --git a/src/app/shared/log-in/methods/mfa/log-in-mfa.component.ts b/src/app/shared/log-in/methods/mfa/log-in-mfa.component.ts index 20d186002bd..841e3236ea6 100644 --- a/src/app/shared/log-in/methods/mfa/log-in-mfa.component.ts +++ b/src/app/shared/log-in/methods/mfa/log-in-mfa.component.ts @@ -15,7 +15,6 @@ import { isMfaVerifying, } from '@dspace/core/auth/selectors'; import { CoreState } from '@dspace/core/core-state.model'; -import { BtnDisabledDirective } from '@dspace/shared/btn-disabled.directive'; import { select, Store, @@ -23,6 +22,8 @@ import { import { TranslateModule } from '@ngx-translate/core'; import { Observable } from 'rxjs'; +import { BtnDisabledDirective } from '../../../../shared/btn-disabled.directive'; + /** * Component shown during the login flow when MFA verification is required. * diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 6551143ff13..875b57da2bb 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -3680,6 +3680,18 @@ "login.title": "Login", + "login.mfa.title": "Two-Factor Authentication", + "login.mfa.description": "Enter the 6-digit code from your authenticator app.", + "login.mfa.recovery.description": "Enter one of your recovery codes.", + "login.mfa.code.label": "Verification code", + "login.mfa.code.placeholder": "000000", + "login.mfa.recovery.label": "Recovery code", + "login.mfa.recovery.placeholder": "Enter recovery code", + "login.mfa.verify.button": "Verify", + "login.mfa.cancel.button": "Cancel", + "login.mfa.use.recovery": "Use a recovery code instead", + "login.mfa.use.totp": "Use authenticator app instead", + "login.breadcrumbs": "Login", "logout.form.header": "Log out from DSpace", @@ -4392,6 +4404,30 @@ "profile.card.security": "Security", + "profile.card.mfa": "Two-Factor Authentication", + "profile.mfa.disabled.description": "Two-factor authentication adds an extra layer of security to your account. When enabled, you'll need to enter a code from your authenticator app after entering your password.", + "profile.mfa.setup.button": "Set up two-factor authentication", + "profile.mfa.setup.instructions": "Scan the QR code below with your authenticator app (Google Authenticator, Authy, etc.), then enter the 6-digit code to confirm setup.", + "profile.mfa.setup.manual": "If you can't scan the QR code, copy this URI into your authenticator app manually:", + "profile.mfa.setup.code.label": "Enter the code from your app", + "profile.mfa.setup.confirm": "Verify and enable", + "profile.mfa.cancel": "Cancel", + "profile.mfa.enabled.badge": "Enabled", + "profile.mfa.enabled.description": "Two-factor authentication is active on your account.", + "profile.mfa.remaining.codes": "You have {{ count }} recovery codes remaining.", + "profile.mfa.disable.title": "Disable two-factor authentication", + "profile.mfa.disable.button": "Disable MFA", + "profile.mfa.regenerate.title": "Regenerate recovery codes", + "profile.mfa.regenerate.button": "Regenerate codes", + "profile.mfa.recovery.title": "Recovery codes", + "profile.mfa.recovery.warning": "Save these codes in a secure place. Each code can only be used once. If you lose access to your authenticator app, you can use one of these codes to log in.", + "profile.mfa.recovery.dismiss": "I've saved my codes", + "profile.mfa.error.status": "Failed to load MFA status.", + "profile.mfa.error.setup": "Failed to initialize MFA setup.", + "profile.mfa.error.verify": "Invalid code. Please try again.", + "profile.mfa.error.disable": "Invalid code. Cannot disable MFA.", + "profile.mfa.error.regenerate": "Invalid code. Cannot regenerate recovery codes.", + "profile.form.submit": "Save", "profile.groups.head": "Authorization groups you belong to", diff --git a/src/themes/custom/app/profile-page/profile-page.component.ts b/src/themes/custom/app/profile-page/profile-page.component.ts index 3ffec1b6fdb..21296ecd9ba 100644 --- a/src/themes/custom/app/profile-page/profile-page.component.ts +++ b/src/themes/custom/app/profile-page/profile-page.component.ts @@ -9,6 +9,7 @@ import { TranslateModule } from '@ngx-translate/core'; import { SuggestionsNotificationComponent } from '../../../../app/notifications/suggestions/notification/suggestions-notification.component'; import { ProfilePageComponent as BaseComponent } from '../../../../app/profile-page/profile-page.component'; import { ThemedProfilePageMetadataFormComponent } from '../../../../app/profile-page/profile-page-metadata-form/themed-profile-page-metadata-form.component'; +import { ProfilePageMfaFormComponent } from '../../../../app/profile-page/profile-page-mfa-form/profile-page-mfa-form.component'; import { ProfilePageResearcherFormComponent } from '../../../../app/profile-page/profile-page-researcher-form/profile-page-researcher-form.component'; import { ProfilePageSecurityFormComponent } from '../../../../app/profile-page/profile-page-security-form/profile-page-security-form.component'; import { AlertComponent } from '../../../../app/shared/alert/alert.component'; @@ -29,6 +30,7 @@ import { VarDirective } from '../../../../app/shared/utils/var.directive'; ErrorComponent, NgTemplateOutlet, PaginationComponent, + ProfilePageMfaFormComponent, ProfilePageResearcherFormComponent, ProfilePageSecurityFormComponent, RouterModule,