diff --git a/package-lock.json b/package-lock.json index 8dcf9f65399..35deebac518 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "@ngrx/store": "^20.1.0", "@ngx-translate/core": "^17.0.0", "@nicky-lenaers/ngx-scroll-to": "^14.0.0", + "@popperjs/core": "^2.11.8", "@terraformer/wkt": "^2.2.2", "altcha": "^2.3.0", "angulartics2": "^12.2.0", @@ -77,6 +78,7 @@ "nouislider": "^15.7.1", "orejime": "^2.3.3", "pem": "1.14.8", + "qrcode": "^1.5.4", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.0", "uuid": "^14.0.1", @@ -109,6 +111,7 @@ "@types/js-cookie": "3.0.6", "@types/lodash-es": "^4.17.12", "@types/node": "^20.19.43", + "@types/qrcode": "^1.5.6", "@typescript-eslint/eslint-plugin": "^8.62.1", "@typescript-eslint/parser": "^8.62.1", "@typescript-eslint/rule-tester": "^8.62.1", @@ -10459,7 +10462,6 @@ "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/popperjs" @@ -11486,6 +11488,16 @@ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==" }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/qs": { "version": "6.9.18", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz", @@ -13379,6 +13391,15 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001759", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001759.tgz", @@ -13651,6 +13672,17 @@ "node": ">= 12" } }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, "node_modules/clone": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", @@ -14746,6 +14778,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/deep-freeze": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/deep-freeze/-/deep-freeze-0.0.1.tgz", @@ -14936,6 +14977,12 @@ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dnd-core": { "version": "10.0.2", "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-10.0.2.tgz", @@ -21766,7 +21813,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, "engines": { "node": ">=6" } @@ -21975,7 +22021,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "engines": { "node": ">=8" } @@ -22132,6 +22177,15 @@ "node": ">=16.0.0" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/popper.js": { "version": "1.16.1-lts", "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1-lts.tgz", @@ -23164,6 +23218,23 @@ "node": ">=0.9" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/qs": { "version": "6.14.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", @@ -23882,7 +23953,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -23896,6 +23966,12 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", @@ -24737,6 +24813,12 @@ "node": ">= 0.8.0" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -26895,6 +26977,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/which-typed-array": { "version": "1.1.19", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", @@ -26936,7 +27024,6 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "dev": true, "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -27043,6 +27130,99 @@ "node": ">= 6" } }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, "node_modules/yauzl": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz", diff --git a/package.json b/package.json index 8712ab1eb24..c24f82f9fa9 100644 --- a/package.json +++ b/package.json @@ -100,6 +100,7 @@ "@ngrx/store": "^20.1.0", "@ngx-translate/core": "^17.0.0", "@nicky-lenaers/ngx-scroll-to": "^14.0.0", + "@popperjs/core": "^2.11.8", "@terraformer/wkt": "^2.2.2", "altcha": "^2.3.0", "angulartics2": "^12.2.0", @@ -147,6 +148,7 @@ "nouislider": "^15.7.1", "orejime": "^2.3.3", "pem": "1.14.8", + "qrcode": "^1.5.4", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.0", "uuid": "^14.0.1", @@ -179,6 +181,7 @@ "@types/js-cookie": "3.0.6", "@types/lodash-es": "^4.17.12", "@types/node": "^20.19.43", + "@types/qrcode": "^1.5.6", "@typescript-eslint/eslint-plugin": "^8.62.1", "@typescript-eslint/parser": "^8.62.1", "@typescript-eslint/rule-tester": "^8.62.1", 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..70caafae777 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: @@ -266,6 +280,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..ddd2b4bc596 --- /dev/null +++ b/src/app/core/auth/mfa.effects.ts @@ -0,0 +1,108 @@ +import { + HttpErrorResponse, + 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 err = error as HttpErrorResponse; + const message = err?.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, diff --git a/src/app/profile-page/profile-page-mfa-form/profile-page-mfa-form.component.html b/src/app/profile-page/profile-page-mfa-form/profile-page-mfa-form.component.html new file mode 100644 index 00000000000..ce48a68b363 --- /dev/null +++ b/src/app/profile-page/profile-page-mfa-form/profile-page-mfa-form.component.html @@ -0,0 +1,91 @@ +@if (error$ | async; as error) { + +} + + +@if (recoveryCodes$ | async; as codes) { +
+
{{ 'profile.mfa.recovery.title' | translate }}
+

{{ 'profile.mfa.recovery.warning' | translate }}

+
+ @for (code of codes; track code) { +
{{ code }}
+ } +
+ +
+} + + +@if ((mfaEnabled$ | async) === false && (setupMode$ | async) === false && (recoveryCodes$ | async) === null) { +

{{ 'profile.mfa.disabled.description' | translate }}

+ +} + + +@if (setupMode$ | async) { +
+

{{ 'profile.mfa.setup.instructions' | translate }}

+ @if (provisioningUri$ | async; as uri) { +
+ +
+
+

{{ 'profile.mfa.setup.manual' | translate }}

+ {{ uri }} +
+ } +
+
+ + +
+
+ + +
+
+
+} + + +@if ((mfaEnabled$ | async) === true && (recoveryCodes$ | async) === null) { +

+ {{ 'profile.mfa.enabled.badge' | translate }} + {{ 'profile.mfa.enabled.description' | translate }} +

+

+ {{ 'profile.mfa.remaining.codes' | translate:{ count: (remainingCodes$ | async) } }} +

+ +
+
{{ 'profile.mfa.disable.title' | translate }}
+
+ + +
+
+ +
+
{{ 'profile.mfa.regenerate.title' | translate }}
+
+ + +
+
+} diff --git a/src/app/profile-page/profile-page-mfa-form/profile-page-mfa-form.component.ts b/src/app/profile-page/profile-page-mfa-form/profile-page-mfa-form.component.ts new file mode 100644 index 00000000000..0c75f1675d9 --- /dev/null +++ b/src/app/profile-page/profile-page-mfa-form/profile-page-mfa-form.component.ts @@ -0,0 +1,246 @@ +import { AsyncPipe } from '@angular/common'; +import { + AfterViewChecked, + Component, + ElementRef, + OnInit, + ViewChild, +} from '@angular/core'; +import { + FormBuilder, + FormGroup, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { + MfaService, + MfaStatusResponse, +} from '@dspace/core/auth/mfa.service'; +import { TranslateModule } from '@ngx-translate/core'; +import QRCode from 'qrcode'; +import { BehaviorSubject } from 'rxjs'; + +import { BtnDisabledDirective } from '../../shared/btn-disabled.directive'; + +/** + * Profile page section for managing Multi-Factor Authentication. + * + * This component provides the full MFA management UI allowing users to: + * - View their current MFA status (enabled/disabled, remaining recovery codes) + * - Set up TOTP-based MFA with QR code scanning + * - Confirm setup by verifying a code from their authenticator app + * - Disable MFA (requires current TOTP code) + * - Regenerate recovery codes (requires current TOTP code) + * + * The QR code is rendered to a canvas element using the `qrcode` library + * and is automatically updated via `ngAfterViewChecked` when the provisioning + * URI changes. + * + * @example + * ```html + * + * ``` + */ +@Component({ + selector: 'ds-profile-page-mfa-form', + templateUrl: './profile-page-mfa-form.component.html', + imports: [ + AsyncPipe, + BtnDisabledDirective, + ReactiveFormsModule, + TranslateModule, + ], +}) +export class ProfilePageMfaFormComponent implements OnInit, AfterViewChecked { + /** Reference to the canvas element used for rendering the TOTP QR code. */ + @ViewChild('qrCanvas') qrCanvas: ElementRef; + + /** Tracks the last rendered provisioning URI to avoid redundant QR re-renders. */ + private lastRenderedUri: string = null; + + /** Whether MFA is currently enabled for the user. */ + mfaEnabled$ = new BehaviorSubject(false); + + /** Number of unused recovery codes remaining. */ + remainingCodes$ = new BehaviorSubject(0); + + /** Whether the component is in MFA setup mode (showing QR code and verification form). */ + setupMode$ = new BehaviorSubject(false); + + /** The `otpauth://` provisioning URI for QR code generation, or null if not in setup. */ + provisioningUri$ = new BehaviorSubject(null); + + /** Recovery codes to display to the user after setup or regeneration, or null. */ + recoveryCodes$ = new BehaviorSubject(null); + + /** Current error message translation key, or null if no error. */ + error$ = new BehaviorSubject(null); + + /** Whether an async operation is in progress. */ + loading$ = new BehaviorSubject(false); + + /** Form for verifying the TOTP code during initial MFA setup. */ + verifyForm: FormGroup; + + /** Form for providing TOTP code when disabling MFA. */ + disableForm: FormGroup; + + /** Form for providing TOTP code when regenerating recovery codes. */ + regenForm: FormGroup; + + /** + * @param mfaService - Service for MFA HTTP operations + * @param fb - Angular FormBuilder for creating reactive forms + */ + constructor( + private mfaService: MfaService, + private fb: FormBuilder, + ) { + this.verifyForm = this.fb.group({ code: ['', [Validators.required, Validators.pattern(/^\d{6}$/)]] }); + this.disableForm = this.fb.group({ code: ['', [Validators.required, Validators.pattern(/^\d{6}$/)]] }); + this.regenForm = this.fb.group({ code: ['', [Validators.required, Validators.pattern(/^\d{6}$/)]] }); + } + + /** Loads MFA status on component initialization. */ + ngOnInit(): void { + this.loadStatus(); + } + + /** + * Fetches the current MFA status from the server and updates local state. + * Sets `mfaEnabled$` and `remainingCodes$` on success, or `error$` on failure. + */ + loadStatus(): void { + this.mfaService.getStatus().subscribe({ + next: (status: MfaStatusResponse) => { + this.mfaEnabled$.next(status.enabled); + this.remainingCodes$.next(status.remainingRecoveryCodes); + }, + error: () => this.error$.next('profile.mfa.error.status'), + }); + } + + /** + * Initiates MFA setup by requesting a new TOTP secret from the server. + * On success, enters setup mode and displays the provisioning URI as a QR code. + */ + startSetup(): void { + this.loading$.next(true); + this.error$.next(null); + this.mfaService.setup().subscribe({ + next: (response) => { + this.provisioningUri$.next(response.provisioningUri); + this.setupMode$.next(true); + this.loading$.next(false); + }, + error: (err: unknown) => { + this.error$.next((err as any)?.error?.error || 'profile.mfa.error.setup'); + this.loading$.next(false); + }, + }); + } + + /** + * Confirms MFA setup by verifying the TOTP code entered by the user. + * On success, activates MFA and displays recovery codes. + * Does nothing if the code input is empty. + */ + confirmSetup(): void { + const code = this.verifyForm.get('code').value?.trim(); + if (!code) { return; } + this.loading$.next(true); + this.error$.next(null); + this.mfaService.verifySetup(code).subscribe({ + next: (response) => { + this.recoveryCodes$.next(response.recoveryCodes); + this.mfaEnabled$.next(true); + this.setupMode$.next(false); + this.loading$.next(false); + this.verifyForm.reset(); + }, + error: (err: unknown) => { + this.error$.next((err as any)?.error?.error || 'profile.mfa.error.verify'); + this.loading$.next(false); + }, + }); + } + + /** + * Disables MFA for the user after verifying their TOTP code. + * Resets all MFA-related state on success. + * Does nothing if the code input is empty. + */ + disable(): void { + const code = this.disableForm.get('code').value?.trim(); + if (!code) { return; } + this.loading$.next(true); + this.error$.next(null); + this.mfaService.disable(code).subscribe({ + next: () => { + this.mfaEnabled$.next(false); + this.remainingCodes$.next(0); + this.recoveryCodes$.next(null); + this.loading$.next(false); + this.disableForm.reset(); + }, + error: (err: unknown) => { + this.error$.next((err as any)?.error?.error || 'profile.mfa.error.disable'); + this.loading$.next(false); + }, + }); + } + + /** + * Regenerates recovery codes after verifying the user's TOTP code. + * Invalidates all previously issued codes and displays new ones. + * Does nothing if the code input is empty. + */ + regenerateCodes(): void { + const code = this.regenForm.get('code').value?.trim(); + if (!code) { return; } + this.loading$.next(true); + this.error$.next(null); + this.mfaService.regenerateRecoveryCodes(code).subscribe({ + next: (response) => { + this.recoveryCodes$.next(response.recoveryCodes); + this.remainingCodes$.next(response.recoveryCodes.length); + this.loading$.next(false); + this.regenForm.reset(); + }, + error: (err: unknown) => { + this.error$.next((err as any)?.error?.error || 'profile.mfa.error.regenerate'); + this.loading$.next(false); + }, + }); + } + + /** + * Dismisses the displayed recovery codes and refreshes MFA status. + */ + dismissCodes(): void { + this.recoveryCodes$.next(null); + this.loadStatus(); + } + + /** + * Cancels the MFA setup flow and resets setup-related state. + */ + cancelSetup(): void { + this.setupMode$.next(false); + this.provisioningUri$.next(null); + this.lastRenderedUri = null; + this.verifyForm.reset(); + } + + /** + * Renders the QR code to the canvas element when the provisioning URI changes. + * Uses `lastRenderedUri` to avoid redundant re-renders on each change detection cycle. + */ + ngAfterViewChecked(): void { + const uri = this.provisioningUri$.value; + if (this.qrCanvas?.nativeElement && uri && uri !== this.lastRenderedUri) { + this.lastRenderedUri = uri; + QRCode.toCanvas(this.qrCanvas.nativeElement, uri, { width: 200 }); + } + } +} diff --git a/src/app/profile-page/profile-page.component.html b/src/app/profile-page/profile-page.component.html index 5d21b1578ab..c108d0f982e 100644 --- a/src/app/profile-page/profile-page.component.html +++ b/src/app/profile-page/profile-page.component.html @@ -32,6 +32,12 @@

{{'profile.title' | translate}}

} +
+
{{'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/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) {