From 15aee43cffdb4365e46f5efa9ae652b7d427a191 Mon Sep 17 00:00:00 2001 From: romanetar Date: Mon, 6 Jul 2026 16:03:28 +0200 Subject: [PATCH] feat: recovery code management Signed-off-by: romanetar --- .../Controllers/Api/UserApiController.php | 102 +++++++++- app/Http/Controllers/UserController.php | 12 ++ app/Services/Auth/IRecoveryCodeService.php | 52 +++++ app/Services/Auth/RecoveryCodeService.php | 94 +++++++++ .../Auth/TwoFactorServiceProvider.php | 2 + app/libs/Auth/Models/TwoFactorAuditLog.php | 2 + config/auth.php | 6 + ...tion-not-triggered-by-group-enforcement.md | 91 +++++++++ package.json | 4 +- .../js/components/recovery_code_display.js | 73 +++++++ .../js/components/recovery_code_modal.js | 64 ++++++ .../js/components/recovery_codes.module.scss | 43 ++++ .../js/components/recovery_codes_panel.js | 142 +++++++++++++ resources/js/components/two_factor_section.js | 66 +++++++ resources/js/login/login.js | 6 +- resources/js/profile/actions.js | 12 +- resources/js/profile/profile.js | 33 +++- resources/js/profile/profile.module.scss | 10 + .../shared/{HTMLRender.jsx => HTMLRender.js} | 0 resources/js/utils.js | 12 ++ resources/views/profile.blade.php | 8 +- routes/web.php | 2 + tests/RecoveryCodeRegenerationTest.php | 187 ++++++++++++++++++ .../components/recovery_code_display.test.js | 53 +++++ .../js/components/recovery_code_modal.test.js | 60 ++++++ .../components/recovery_codes_panel.test.js | 105 ++++++++++ .../js/components/two_factor_section.test.js | 48 +++++ tests/js/setup.js | 11 ++ yarn.lock | 4 +- 29 files changed, 1290 insertions(+), 14 deletions(-) create mode 100644 app/Services/Auth/IRecoveryCodeService.php create mode 100644 app/Services/Auth/RecoveryCodeService.php create mode 100644 doc/adrs/0001-recovery-code-generation-not-triggered-by-group-enforcement.md create mode 100644 resources/js/components/recovery_code_display.js create mode 100644 resources/js/components/recovery_code_modal.js create mode 100644 resources/js/components/recovery_codes.module.scss create mode 100644 resources/js/components/recovery_codes_panel.js create mode 100644 resources/js/components/two_factor_section.js rename resources/js/shared/{HTMLRender.jsx => HTMLRender.js} (100%) create mode 100644 tests/RecoveryCodeRegenerationTest.php create mode 100644 tests/js/components/recovery_code_display.test.js create mode 100644 tests/js/components/recovery_code_modal.test.js create mode 100644 tests/js/components/recovery_codes_panel.test.js create mode 100644 tests/js/components/two_factor_section.test.js diff --git a/app/Http/Controllers/Api/UserApiController.php b/app/Http/Controllers/Api/UserApiController.php index b32c7307..78b051ba 100644 --- a/app/Http/Controllers/Api/UserApiController.php +++ b/app/Http/Controllers/Api/UserApiController.php @@ -15,7 +15,10 @@ use App\Http\Controllers\APICRUDController; use App\Http\Controllers\Traits\RequestProcessor; use App\Http\Controllers\UserValidationRulesFactory; +use App\libs\Auth\Models\TwoFactorAuditLog; use App\ModelSerializers\SerializerRegistry; +use App\Services\Auth\IRecoveryCodeService; +use App\Services\Auth\ITwoFactorAuditService; use Auth\Repositories\IUserRepository; use Auth\User; use Exception; @@ -23,10 +26,13 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Request; +use Illuminate\Support\Facades\Validator; use models\exceptions\EntityNotFoundException; use models\exceptions\ValidationException; use OAuth2\Services\ITokenService; use OpenId\Services\IUserService; +use Utils\Db\ITransactionService; +use Utils\IPHelper; use Utils\Services\ILogService; /** @@ -43,23 +49,47 @@ final class UserApiController extends APICRUDController */ private $token_service; + /** + * @var IRecoveryCodeService + */ + private $recovery_code_service; + + /** + * @var ITransactionService + */ + private $tx_service; + + /** + * @var ITwoFactorAuditService + */ + private $two_factor_audit_service; + /** * UserApiController constructor. * @param IUserRepository $user_repository * @param ILogService $log_service * @param IUserService $user_service * @param ITokenService $token_service + * @param IRecoveryCodeService $recovery_code_service + * @param ITransactionService $tx_service + * @param ITwoFactorAuditService $two_factor_audit_service */ public function __construct ( IUserRepository $user_repository, ILogService $log_service, IUserService $user_service, - ITokenService $token_service + ITokenService $token_service, + IRecoveryCodeService $recovery_code_service, + ITransactionService $tx_service, + ITwoFactorAuditService $two_factor_audit_service ) { parent::__construct($user_repository, $user_service, $log_service); $this->token_service = $token_service; + $this->recovery_code_service = $recovery_code_service; + $this->tx_service = $tx_service; + $this->two_factor_audit_service = $two_factor_audit_service; } /** @@ -247,6 +277,76 @@ public function updateMe() return $this->update(Auth::user()->getId()); } + /** + * Enables a 2FA method for the current user and generates the first batch of + * recovery codes for them. Plaintext codes are returned once in the response + * and never persisted. + * + * @return \Illuminate\Http\JsonResponse|mixed + */ + public function enableTwoFactor() + { + if (!Auth::check()) + return $this->error403(); + + return $this->processRequest(function () { + $data = Request::all(); + $validator = Validator::make($data, [ + 'method' => 'required|string|in:' . implode(',', User::ValidMFAMethods), + ]); + + if (!$validator->passes()) { + return $this->error412($validator->getMessageBag()->getMessages()); + } + + $user = Auth::user(); + $method = $data['method']; + + $codes = $this->tx_service->transaction(function () use ($user, $method) { + $user->enable2FA($method); + $this->repository->add($user, false); + + return $this->recovery_code_service->generateRecoveryCodes($user); + }); + + $this->two_factor_audit_service->log( + $user, + TwoFactorAuditLog::EventEnrollmentChanged, + $method, + IPHelper::getUserIp() + ); + + return $this->ok(['recovery_codes' => $codes]); + }); + } + + /** + * Invalidates the current user's recovery codes and generates a fresh batch. + * Plaintext codes are returned once in the response and never persisted. + * + * @return \Illuminate\Http\JsonResponse|mixed + */ + public function regenerateRecoveryCodes() + { + if (!Auth::check()) + return $this->error403(); + + return $this->processRequest(function () { + $data = Request::all(); + $validator = Validator::make($data, [ + 'current_password' => 'required|string', + ]); + + if (!$validator->passes()) { + return $this->error412($validator->getMessageBag()->getMessages()); + } + + $codes = $this->recovery_code_service->regenerateRecoveryCodes(Auth::user(), $data['current_password']); + + return $this->ok(['recovery_codes' => $codes]); + }); + } + public function revokeAllMyTokens() { if (!Auth::check()) diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index b37ccdb3..9c205a67 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -21,6 +21,7 @@ use App\Http\Utils\CountryList; use App\libs\Auth\Models\TwoFactorAuditLog; use App\Services\Auth\IDeviceTrustService; +use App\Services\Auth\IRecoveryCodeService; use App\Services\Auth\ITwoFactorAuditService; use App\Services\Auth\ITwoFactorGateService; use Auth\User; @@ -157,6 +158,11 @@ final class UserController extends OpenIdController */ private $mfa_gate_service; + /** + * @var IRecoveryCodeService + */ + private $recovery_code_service; + /** * @param IMementoOpenIdSerializerService $openid_memento_service * @param IMementoOAuth2SerializerService $oauth2_memento_service @@ -196,6 +202,7 @@ public function __construct IDeviceTrustService $device_trust_service, ITwoFactorAuditService $two_factor_audit_service, ITwoFactorGateService $mfa_gate_service, + IRecoveryCodeService $recovery_code_service, ) { $this->openid_memento_service = $openid_memento_service; @@ -216,6 +223,7 @@ public function __construct $this->device_trust_service = $device_trust_service; $this->two_factor_audit_service = $two_factor_audit_service; $this->mfa_gate_service = $mfa_gate_service; + $this->recovery_code_service = $recovery_code_service; $this->middleware(function ($request, $next) use($login_hint_process_strategy){ @@ -1007,6 +1015,10 @@ public function getProfile() 'actions' => $actions, 'countries' => CountryList::getCountries(), 'languages' => $lang2Code, + 'two_factor_enabled' => $user->shouldRequire2FA(), + 'recovery_codes_remaining' => $this->recovery_code_service->countUnusedRecoveryCodes($user), + 'recovery_codes_total' => (int)config('auth.recovery_codes.count', 10), + 'recovery_codes_low_threshold' => (int)config('auth.recovery_codes.low_threshold', 3), ]); } diff --git a/app/Services/Auth/IRecoveryCodeService.php b/app/Services/Auth/IRecoveryCodeService.php new file mode 100644 index 00000000..56c9d464 --- /dev/null +++ b/app/Services/Auth/IRecoveryCodeService.php @@ -0,0 +1,52 @@ +checkPassword(trim($currentPassword))) { + throw new ValidationException('current_password is not correct.'); + } + + return $this->generateRecoveryCodes($user); + } + + /** + * @inheritDoc + */ + public function generateRecoveryCodes(User $user): array + { + $count = (int)config('auth.recovery_codes.count', 10); + $length = (int)config('auth.recovery_codes.length', 8); + + $plaintext_codes = []; + + $this->tx_service->transaction(function () use ($user, $count, $length, &$plaintext_codes) { + $this->repository->deleteAllForUser($user); + + for ($i = 0; $i < $count; $i++) { + $plain = Rand::getString($length, self::CODE_CHARSET, true); + $plaintext_codes[] = $plain; + + $code = new UserRecoveryCode(); + $code->setUser($user); + $code->setCodeHash(Hash::make($plain)); + $this->repository->add($code, false); + } + }); + + $this->audit_service->log( + $user, + TwoFactorAuditLog::EventRecoveryCodesGenerated, + TwoFactorAuditLog::MethodRecovery, + IPHelper::getUserIp() + ); + + return array_map(static fn(string $code) => implode('-', str_split($code, 4)), $plaintext_codes); + } + + /** + * @inheritDoc + */ + public function countUnusedRecoveryCodes(User $user): int + { + return count($this->repository->getUnusedByUser($user)); + } +} diff --git a/app/Services/Auth/TwoFactorServiceProvider.php b/app/Services/Auth/TwoFactorServiceProvider.php index 081eed76..5cdbe29a 100644 --- a/app/Services/Auth/TwoFactorServiceProvider.php +++ b/app/Services/Auth/TwoFactorServiceProvider.php @@ -34,6 +34,7 @@ public function register(): void $this->app->singleton(IDeviceTrustService::class, DeviceTrustService::class); $this->app->singleton(ITwoFactorAuditService::class, TwoFactorAuditService::class); $this->app->singleton(ITwoFactorGateService::class, MFAGateService::class); + $this->app->singleton(IRecoveryCodeService::class, RecoveryCodeService::class); } public function provides(): array @@ -42,6 +43,7 @@ public function provides(): array IDeviceTrustService::class, ITwoFactorAuditService::class, ITwoFactorGateService::class, + IRecoveryCodeService::class, ]; } } diff --git a/app/libs/Auth/Models/TwoFactorAuditLog.php b/app/libs/Auth/Models/TwoFactorAuditLog.php index 4938345a..5258978a 100644 --- a/app/libs/Auth/Models/TwoFactorAuditLog.php +++ b/app/libs/Auth/Models/TwoFactorAuditLog.php @@ -29,6 +29,7 @@ class TwoFactorAuditLog extends BaseEntity public const EventDeviceRevoked = 'device_revoked'; public const EventRecoveryUsed = 'recovery_used'; public const EventSettingsChanged = 'settings_changed'; + public const EventRecoveryCodesGenerated = 'recovery_codes_generated'; public const MethodEmailOtp = 'email_otp'; public const MethodSmsOtp = 'sms_otp'; @@ -46,6 +47,7 @@ class TwoFactorAuditLog extends BaseEntity self::EventDeviceRevoked, self::EventRecoveryUsed, self::EventSettingsChanged, + self::EventRecoveryCodesGenerated, ]; private const ALLOWED_METHODS = [ diff --git a/config/auth.php b/config/auth.php index 5da1b184..69282ef7 100644 --- a/config/auth.php +++ b/config/auth.php @@ -107,6 +107,12 @@ 'password_shape_warning' => env('AUTH_PASSWORD_SHAPE_WARNING', 'Password must include at least one uppercase letter, one lowercase letter, one number, and one special character (#?!@$%^&*+-).'), 'verification_email_lifetime' => env("AUTH_VERIFICATION_EMAIL_LIFETIME", 600), 'allows_native_auth' => env('AUTH_ALLOWS_NATIVE_AUTH', 1), + + 'recovery_codes' => [ + 'count' => env('MFA_RECOVERY_CODES_COUNT', 10), + 'length' => env('MFA_RECOVERY_CODE_LENGTH', 8), + 'low_threshold' => env('MFA_RECOVERY_CODES_LOW_THRESHOLD', 3), + ], 'allows_native_on_config' => env('AUTH_ALLOWS_NATIVE_AUTH_CONFIG', 1), 'allows_opt_auth' => env('AUTH_ALLOWS_OTP_AUTH', 1), ]; diff --git a/doc/adrs/0001-recovery-code-generation-not-triggered-by-group-enforcement.md b/doc/adrs/0001-recovery-code-generation-not-triggered-by-group-enforcement.md new file mode 100644 index 00000000..31ed8bbd --- /dev/null +++ b/doc/adrs/0001-recovery-code-generation-not-triggered-by-group-enforcement.md @@ -0,0 +1,91 @@ +# 0001. Recovery code generation is not automatically triggered by group-based 2FA enforcement + +## Status + +Accepted — 2026-07-08 + +## Context + +Ticket [CU-86ba2zp66](https://app.clickup.com/t/86ba2zp66) ("Recovery Code Management UI and Endpoints") requires: + +> Create endpoint to generate recovery codes **when 2FA is enabled or admin enforcement requires code creation**. + +Two-factor authentication in this project can become mandatory for a user in two distinct ways: + +1. **Explicit self-enrollment** — the user calls the new `UserApiController::enableTwoFactor()` endpoint + (`app/Http/Controllers/Api/UserApiController.php`), which invokes `User::enable2FA($method)` and, in the + same transaction, calls `RecoveryCodeService::generateRecoveryCodes()` to mint the user's first batch of + recovery codes. +2. **Group-based enforcement** — `User::shouldRequire2FA()` (`app/libs/Auth/Models/User.php`) returns `true` + for any user belonging to one of the groups listed in `config('two_factor.enforced_groups')` + (`config/two_factor.php`: SuperAdmin, Admin, OAuth2ServerAdmin, OpenIdServerAdmins), **independently** of + whether that user ever called `enableTwoFactor()` or has the `two_factor_enabled` column set. + +Only path (1) generates recovery codes automatically. Path (2) has no corresponding hook: there is no +listener on group-membership assignment (e.g. when a user is added to the `Admin` group via +`GroupApiController` or any other admin-management flow) that generates a recovery-code batch for that user. + +Practical consequence: a user who is force-enrolled into MFA purely by being added to an enforced group — +and who never separately visits their profile to enable 2FA or regenerate codes — has **zero recovery codes** +until they proactively open their profile's "Two-Factor Authentication" section and click "Regenerate Codes" +(`resources/js/components/recovery_codes_panel.js`). That manual path works correctly and is not gated on +prior enrollment, but it is not automatic, so the literal wording of the ticket ("or admin enforcement +requires code creation") is not satisfied for this path. + +Implementing the automatic path would require identifying every place group membership can be granted +(direct admin action, bulk import, programmatic group assignment, etc.) and wiring a listener/hook into each +one to call `RecoveryCodeService::generateRecoveryCodes()` exactly once per user, without duplicating codes +on repeated grants or interfering with a user who already regenerated codes themselves. No single +well-defined integration point for "group membership changed" was identified during implementation without +a dedicated exploration pass, and building one was judged to be a meaningfully larger change than the rest +of this ticket. + +## Decision + +We accept the gap as a documented scope limitation for this ticket. Recovery code generation for +group-enforced users remains **on-demand**: the user (or an admin acting on their behalf, e.g. via a support +flow) must visit the profile's Two-Factor Authentication section and use "Regenerate Codes" at least once +after being enrolled through group enforcement. + +No code changes accompany this decision; it documents the trade-off already present in the shipped +implementation (`feat/recovery-codes-management` branch). + +## Consequences + +**Positive** + +- No new event/listener infrastructure needed for group-membership changes, keeping the change surface of + this ticket limited to the profile self-service flow it was originally scoped around. +- The manual path is simple, already implemented, and requires no additional user-facing concept: a + group-enforced user sees the same "Two-Factor Authentication" section and the same "Regenerate Codes" + action as anyone who self-enrolled. +- Avoids the risk of generating recovery codes an admin never asked for or expects during unrelated + group-management operations (e.g. bulk group imports). + +**Negative** + +- A user who is force-enrolled by group membership and is challenged for MFA (e.g. at their next login) + before ever visiting their profile has **no recovery codes available** if they lose access to their normal + 2FA method (email, for Phase I) at that point. Their only recourse is out-of-band administrative + intervention (e.g. a server admin resetting their 2FA state directly), not a self-service recovery path. +- The literal acceptance criterion "generate recovery codes ... when admin enforcement requires code + creation" is not met for the group-enforcement path — only for explicit self-enrollment. + +**Follow-up (not scheduled)** + +If this gap needs to be closed later, the natural integration point is wherever group membership is granted +(`GroupApiController` and any other code path that adds a user to `Group`) — call +`RecoveryCodeService::generateRecoveryCodes($user)` immediately after granting membership to a group in +`config('two_factor.enforced_groups')`, guarded so it only fires when the user currently has zero unused +codes (to avoid clobbering codes on every re-grant). + +## Alternatives considered + +- **Hook into group-assignment code paths now.** Rejected for this ticket: requires auditing every place + group membership can change (there is more than one — see `GroupApiController` and related admin flows) + to guarantee the hook fires exactly once and doesn't silently invalidate codes a user already saved. Judged + to be new scope beyond "Recovery Code Management UI and Endpoints," better handled as its own ticket if + the org decides to close this gap. +- **Generate codes lazily on first MFA challenge instead of on group grant.** Rejected: the MFA challenge + screen itself has no natural place to show a one-time "here are your recovery codes" modal without + interrupting the login flow the ticket explicitly requires to remain "unaffected." diff --git a/package.json b/package.json index 0992db7c..96e436f3 100644 --- a/package.json +++ b/package.json @@ -27,9 +27,9 @@ "@babel/preset-react": "^7.7.4", "@babel/runtime": "^7.20.7", "@playwright/test": "^1.61.1", - "@testing-library/jest-dom": "^5", - "@testing-library/react": "^12", "@testing-library/user-event": "^13", + "@testing-library/jest-dom": "^5.16.5", + "@testing-library/react": "^12.1.5", "babel-cli": "^6.26.0", "babel-jest": "^26.6.3", "babel-loader": "^8.2.4", diff --git a/resources/js/components/recovery_code_display.js b/resources/js/components/recovery_code_display.js new file mode 100644 index 00000000..32a24c2d --- /dev/null +++ b/resources/js/components/recovery_code_display.js @@ -0,0 +1,73 @@ +import React, {useState} from "react"; +import Box from "@material-ui/core/Box"; +import Button from "@material-ui/core/Button"; +import Grid from "@material-ui/core/Grid"; +import Typography from "@material-ui/core/Typography"; +import AssignmentIcon from "@material-ui/icons/Assignment"; +import CheckCircleIcon from "@material-ui/icons/CheckCircle"; +import {downloadTextFile} from "../utils"; + +import styles from "./recovery_codes.module.scss"; + +const buildFileContent = (codes, email) => { + const date = new Date().toISOString().slice(0, 10); + return [ + "FNTECH Recovery Codes", + `Generated: ${date}`, + `Account: ${email}`, + "", + "Keep these codes somewhere safe. Each code can only be used once to sign in, and they will not be shown again.", + "", + ...codes, + ].join("\n"); +}; + +const RecoveryCodeDisplay = ({codes, email}) => { + const [copied, setCopied] = useState(false); + + const handleCopy = () => { + navigator.clipboard.writeText(codes.join("\n")).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + }; + + const handleDownload = () => { + const date = new Date().toISOString().slice(0, 10); + downloadTextFile(`fntech-recovery-codes-${date}.txt`, buildFileContent(codes, email)); + }; + + return ( + + + {codes.map((code, idx) => ( + + {code} + + ))} + + + +   + + + + ); +}; + +export default RecoveryCodeDisplay; diff --git a/resources/js/components/recovery_code_modal.js b/resources/js/components/recovery_code_modal.js new file mode 100644 index 00000000..0e460137 --- /dev/null +++ b/resources/js/components/recovery_code_modal.js @@ -0,0 +1,64 @@ +import React, {useEffect, useState} from "react"; +import Box from "@material-ui/core/Box"; +import Button from "@material-ui/core/Button"; +import Dialog from "@material-ui/core/Dialog"; +import DialogActions from "@material-ui/core/DialogActions"; +import DialogContent from "@material-ui/core/DialogContent"; +import DialogTitle from "@material-ui/core/DialogTitle"; +import Typography from "@material-ui/core/Typography"; +import WarningRoundedIcon from "@material-ui/icons/WarningRounded"; +import RecoveryCodeDisplay from "./recovery_code_display"; + +import styles from "./recovery_codes.module.scss"; + +const ACK_DELAY_SECONDS = 5; + +const RecoveryCodeModal = ({open, codes, email, onAcknowledge}) => { + const [secondsLeft, setSecondsLeft] = useState(ACK_DELAY_SECONDS); + + useEffect(() => { + if (!open) return undefined; + + setSecondsLeft(ACK_DELAY_SECONDS); + const interval = setInterval(() => { + setSecondsLeft((prev) => (prev > 0 ? prev - 1 : 0)); + }, 1000); + + return () => clearInterval(interval); + }, [open]); + + return ( + + Save Your Recovery Codes + + + + + These codes will not be shown again. Copy or download them now and store them somewhere safe. + + + {codes && } + + + + + + ); +}; + +export default RecoveryCodeModal; diff --git a/resources/js/components/recovery_codes.module.scss b/resources/js/components/recovery_codes.module.scss new file mode 100644 index 00000000..c4437a1e --- /dev/null +++ b/resources/js/components/recovery_codes.module.scss @@ -0,0 +1,43 @@ +.recovery_codes_panel { + margin-top: 15px; +} + +.codes_grid { + margin: 8px 0; + padding: 12px; + background-color: #f5f5f5; + border-radius: 4px; +} + +.code { + font-family: monospace; + font-size: 1rem; + letter-spacing: 1px; +} + +.warning_banner { + display: flex; + align-items: flex-start; + margin-bottom: 16px; + padding: 10px 14px; + background-color: #fdecea; + border-left: 4px solid #f44336; + border-radius: 4px; +} + +.warning_icon { + color: #f44336; + margin-right: 10px; + margin-top: 1px; + flex-shrink: 0; +} + +.low_code_warning { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 8px; + padding: 8px 12px; + background-color: #fff3e0; + border-radius: 4px; +} diff --git a/resources/js/components/recovery_codes_panel.js b/resources/js/components/recovery_codes_panel.js new file mode 100644 index 00000000..9b8bf9f6 --- /dev/null +++ b/resources/js/components/recovery_codes_panel.js @@ -0,0 +1,142 @@ +import React, {useState} from "react"; +import Box from "@material-ui/core/Box"; +import Button from "@material-ui/core/Button"; +import Grid from "@material-ui/core/Grid"; +import IconButton from "@material-ui/core/IconButton"; +import Link from "@material-ui/core/Link"; +import TextField from "@material-ui/core/TextField"; +import Typography from "@material-ui/core/Typography"; +import CloseIcon from "@material-ui/icons/Close"; +import {regenerateRecoveryCodes} from "../profile/actions"; +import {handleErrorResponse} from "../utils"; +import RecoveryCodeModal from "./recovery_code_modal"; + +import styles from "./recovery_codes.module.scss"; + +const DEFAULT_LOW_CODE_THRESHOLD = 3; +const LOW_CODE_WARNING_DISMISSED_KEY = "recovery_codes_low_warning_dismissed"; + +const RecoveryCodesPanel = ({ + recoveryCodesRemaining, + recoveryCodesTotal, + lowCodeThreshold = DEFAULT_LOW_CODE_THRESHOLD, + email, + initialCodes = null + }) => { + const [regenerating, setRegenerating] = useState(false); + const [currentPassword, setCurrentPassword] = useState(""); + const [loading, setLoading] = useState(false); + const [remaining, setRemaining] = useState(recoveryCodesRemaining); + const [total, setTotal] = useState(recoveryCodesTotal); + const [codes, setCodes] = useState(initialCodes); + const [warningDismissed, setWarningDismissed] = useState( + sessionStorage.getItem(LOW_CODE_WARNING_DISMISSED_KEY) === "1" + ); + + const handleRegenerate = () => { + setLoading(true); + regenerateRecoveryCodes(currentPassword).then(({response}) => { + setLoading(false); + setRegenerating(false); + setCurrentPassword(""); + setCodes(response.recovery_codes); + setRemaining(response.recovery_codes.length); + setTotal(response.recovery_codes.length); + }).catch((err) => { + setLoading(false); + handleErrorResponse(err); + }); + }; + + const handleAcknowledge = () => { + setCodes(null); + }; + + const dismissLowCodeWarning = () => { + sessionStorage.setItem(LOW_CODE_WARNING_DISMISSED_KEY, "1"); + setWarningDismissed(true); + }; + + const showLowCodeWarning = !warningDismissed && remaining < lowCodeThreshold; + + return ( + <> + + + Recovery Codes: {remaining} of {total} remaining + + { + showLowCodeWarning && + + + You're running low on recovery codes. Regenerate them to avoid getting locked out. + + + + + + } + { + !regenerating && + { + e.preventDefault(); + setRegenerating(true); + }}> + Regenerate Codes + + } + { + regenerating && + + setCurrentPassword(e.target.value)} + onKeyDown={(e) => { + // This panel lives inside the profile page's own
; + // it must not let Enter bubble up and submit that form too. + if (e.key === "Enter") { + e.preventDefault(); + if (currentPassword && !loading) handleRegenerate(); + } + }} + // Detaches this input from the ancestor (the profile page + // wraps everything in one big form) so the browser's native + // "Enter submits the enclosing form" / password-manager-driven + // auto-submit can never fire a GET on it, regardless of the + // onKeyDown handler above. + inputProps={{form: "recovery-codes-detached-form", autoComplete: "off"}} + data-testid="recovery-codes-current-password" + /> +   + +   + { + e.preventDefault(); + setRegenerating(false); + setCurrentPassword(""); + }}> + Cancel + + + } + + + + ); +}; + +export default RecoveryCodesPanel; diff --git a/resources/js/components/two_factor_section.js b/resources/js/components/two_factor_section.js new file mode 100644 index 00000000..6d0c7e74 --- /dev/null +++ b/resources/js/components/two_factor_section.js @@ -0,0 +1,66 @@ +import React, {useState} from "react"; +import Button from "@material-ui/core/Button"; +import Typography from "@material-ui/core/Typography"; +import {enableTwoFactor} from "../profile/actions"; +import {handleErrorResponse} from "../utils"; +import RecoveryCodesPanel from "./recovery_codes_panel"; + +const DEFAULT_METHOD = "email_otp"; + +const TwoFactorSection = ({ + twoFactorEnabled, + recoveryCodesRemaining, + recoveryCodesTotal, + recoveryCodesLowThreshold, + email + }) => { + const [enabled, setEnabled] = useState(twoFactorEnabled); + const [loading, setLoading] = useState(false); + const [remaining, setRemaining] = useState(recoveryCodesRemaining); + const [total, setTotal] = useState(recoveryCodesTotal); + const [enrollmentCodes, setEnrollmentCodes] = useState(null); + + const handleEnable = () => { + setLoading(true); + enableTwoFactor(DEFAULT_METHOD).then(({response}) => { + setLoading(false); + setRemaining(response.recovery_codes.length); + setTotal(response.recovery_codes.length); + setEnrollmentCodes(response.recovery_codes); + setEnabled(true); + }).catch((err) => { + setLoading(false); + handleErrorResponse(err); + }); + }; + + if (!enabled) { + return ( + <> + + Two-factor authentication is not enabled for your account. + + + + ); + } + + return ( + + ); +}; + +export default TwoFactorSection; diff --git a/resources/js/login/login.js b/resources/js/login/login.js index ccc552d9..8fdb7230 100644 --- a/resources/js/login/login.js +++ b/resources/js/login/login.js @@ -329,9 +329,13 @@ class LoginPage extends React.Component { onRecoveryCodeChange(ev) { let { value } = ev.target; + // Recovery codes are generated and hashed without the "-" separator; it is + // added only for on-screen readability (XXXX-XXXX). Strip any non-alphanumeric + // characters here so a code typed or pasted exactly as displayed still matches. + const normalized = value.replace(/[^A-Za-z0-9]/g, "").toUpperCase(); this.setState({ ...this.state, - recoveryCode: value, + recoveryCode: normalized, errors: { ...this.state.errors, recovery: "" }, }); } diff --git a/resources/js/profile/actions.js b/resources/js/profile/actions.js index c1c6c2f0..0c9ea422 100644 --- a/resources/js/profile/actions.js +++ b/resources/js/profile/actions.js @@ -1,4 +1,4 @@ -import {deleteRawRequest, getRawRequest, putFile, putRawRequest} from "../base_actions"; +import {deleteRawRequest, getRawRequest, postRawRequestFull, putFile, putRawRequest} from "../base_actions"; import moment from "moment"; export const PAGE_SIZE = 10; @@ -87,6 +87,16 @@ export const revokeAllTokens = async () => { return deleteRawRequest(window.REVOKE_ALL_TOKENS_ENDPOINT)({'X-CSRF-TOKEN': window.CSFR_TOKEN}); } +export const regenerateRecoveryCodes = async (currentPassword) => { + const params = {current_password: currentPassword}; + return postRawRequestFull(window.REGENERATE_RECOVERY_CODES_ENDPOINT)(params, {'X-CSRF-TOKEN': window.CSFR_TOKEN}); +} + +export const enableTwoFactor = async (method) => { + const params = {method}; + return postRawRequestFull(window.ENABLE_TWO_FACTOR_ENDPOINT)(params, {'X-CSRF-TOKEN': window.CSFR_TOKEN}); +} + const normalizeEntity = (entity) => { entity.public_profile_show_photo = entity.public_profile_show_photo ? 1 : 0; entity.public_profile_show_fullname = entity.public_profile_show_fullname ? 1 : 0; diff --git a/resources/js/profile/profile.js b/resources/js/profile/profile.js index a530d04a..817845c6 100644 --- a/resources/js/profile/profile.js +++ b/resources/js/profile/profile.js @@ -1,5 +1,6 @@ import React, {useState} from "react"; import ReactDOM from "react-dom"; +import Box from "@material-ui/core/Box"; import Button from "@material-ui/core/Button"; import Card from "@material-ui/core/Card"; import CardContent from "@material-ui/core/CardContent"; @@ -26,6 +27,7 @@ import Navbar from "../components/navbar/navbar"; import Divider from "@material-ui/core/Divider"; import Link from "@material-ui/core/Link"; import PasswordChangePanel from "../components/password_change_panel"; +import TwoFactorSection from "../components/two_factor_section"; import LoadingIndicator from "../components/loading_indicator"; import TopLogo from "../components/top_logo/top_logo"; import {handleErrorResponse} from "../utils"; @@ -41,7 +43,11 @@ const ProfilePage = ({ languages, menuConfig, passwordPolicy, - redirectUri + redirectUri, + twoFactorEnabled, + recoveryCodesRemaining, + recoveryCodesTotal, + recoveryCodesLowThreshold }) => { const [pic, setPic] = useState(null); const [loading, setLoading] = useState(false); @@ -754,11 +760,26 @@ const ProfilePage = ({ )} - - + + + + + + + Two-Factor Authentication + + + + { return res; } +export const downloadTextFile = (filename, content) => { + const blob = new Blob([content], {type: 'text/plain'}); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +}; + export const decodeHtmlEntities = (text) => { const textarea = document.createElement('textarea'); textarea.innerHTML = text; diff --git a/resources/views/profile.blade.php b/resources/views/profile.blade.php index 335094e7..672081ba 100644 --- a/resources/views/profile.blade.php +++ b/resources/views/profile.blade.php @@ -103,7 +103,11 @@ initialValues: initialValues, languages: languages, menuConfig: menuConfig, - passwordPolicy: passwordPolicy + passwordPolicy: passwordPolicy, + twoFactorEnabled: {{ $two_factor_enabled ? 'true' : 'false' }}, + recoveryCodesRemaining: {{ (int) $recovery_codes_remaining }}, + recoveryCodesTotal: {{ (int) $recovery_codes_total }}, + recoveryCodesLowThreshold: {{ (int) $recovery_codes_low_threshold }} } window.GET_USER_ACTIONS_ENDPOINT = '{{URL::action("Api\UserActionApiController@getActionsByCurrentUser")}}'; @@ -112,6 +116,8 @@ window.REVOKE_ALL_TOKENS_ENDPOINT = '{{URL::action("Api\UserApiController@revokeAllMyTokens")}}'; window.SAVE_PROFILE_ENDPOINT = '{!!URL::action("Api\UserApiController@updateMe")!!}'; window.SAVE_PIC_ENDPOINT = '{!!URL::action("Api\UserApiController@updateMyPic")!!}'; + window.REGENERATE_RECOVERY_CODES_ENDPOINT = '{!!URL::action("Api\UserApiController@regenerateRecoveryCodes")!!}'; + window.ENABLE_TWO_FACTOR_ENDPOINT = '{!!URL::action("Api\UserApiController@enableTwoFactor")!!}'; window.CSFR_TOKEN = document.head.querySelector('meta[name="csrf-token"]').content; {!! script_to('assets/profile.js') !!} diff --git a/routes/web.php b/routes/web.php index 5c0f0af0..34133319 100644 --- a/routes/web.php +++ b/routes/web.php @@ -197,6 +197,8 @@ Route::put('', "UserApiController@updateMe"); Route::put('pic', "UserApiController@updateMyPic"); Route::get('actions', "UserActionApiController@getActionsByCurrentUser"); + Route::post('recovery-codes/regenerate', "UserApiController@regenerateRecoveryCodes"); + Route::post('2fa/enable', "UserApiController@enableTwoFactor"); }); Route::get('access-tokens', ['middleware' => ['openstackid.currentuser.serveradmin.json'], 'uses' => 'ClientApiController@getAllAccessTokens']); diff --git a/tests/RecoveryCodeRegenerationTest.php b/tests/RecoveryCodeRegenerationTest.php new file mode 100644 index 00000000..f878e972 --- /dev/null +++ b/tests/RecoveryCodeRegenerationTest.php @@ -0,0 +1,187 @@ +withoutMiddleware(); + $this->be($this->admin()); + Session::start(); + } + + public function testRegenerateWithCorrectPasswordInvalidatesOldCodesAndReturnsNewOnes(): void + { + $admin = $this->admin(); + $this->createRecoveryCode($admin, 'OLD-CODE-' . uniqid(), false); + + $response = $this->regenerate(self::SEED_PASSWORD); + + $this->assertResponseStatus(200); + $payload = json_decode($response->getContent(), true); + $this->assertArrayHasKey('recovery_codes', $payload); + + $expectedCount = (int)config('auth.recovery_codes.count', 10); + $this->assertCount($expectedCount, $payload['recovery_codes']); + foreach ($payload['recovery_codes'] as $code) { + $this->assertMatchesRegularExpression('/^[A-Z0-9]+-[A-Z0-9]+$/', $code); + } + + EntityManager::clear(); + $remaining = EntityManager::getRepository(UserRecoveryCode::class) + ->findBy(['user' => $admin->getId(), 'used_at' => null]); + $this->assertCount( + $expectedCount, + $remaining, + 'old codes must be invalidated and replaced by exactly the configured count' + ); + } + + public function testRegenerateWithWrongPasswordFailsAndDoesNotTouchExistingCodes(): void + { + $admin = $this->admin(); + $plain = 'KEEP-ME-' . uniqid(); + $this->createRecoveryCode($admin, $plain, false); + + $response = $this->regenerate('this-is-not-the-password'); + + $this->assertResponseStatus(412); + + EntityManager::clear(); + $remaining = EntityManager::getRepository(UserRecoveryCode::class) + ->findBy(['user' => $admin->getId(), 'used_at' => null]); + $this->assertNotEmpty($remaining, 'existing codes must not be touched when the password confirmation fails'); + } + + public function testRegenerateRequiresCurrentPassword(): void + { + $response = $this->action('POST', 'Api\\UserApiController@regenerateRecoveryCodes', [], [], [], []); + + $this->assertResponseStatus(412); + } + + public function testRegenerateLogsAuditEvent(): void + { + $admin = $this->admin(); + + $this->regenerate(self::SEED_PASSWORD); + + EntityManager::clear(); + $entries = EntityManager::getRepository(TwoFactorAuditLog::class) + ->findBy(['user' => $admin->getId(), 'event_type' => TwoFactorAuditLog::EventRecoveryCodesGenerated]); + $this->assertNotEmpty($entries, 'a recovery_codes_generated audit entry must be recorded'); + } + + public function testEnableTwoFactorGeneratesRecoveryCodes(): void + { + $admin = $this->admin(); + + $response = $this->enableTwoFactor('email_otp'); + + $this->assertResponseStatus(200); + $payload = json_decode($response->getContent(), true); + $this->assertArrayHasKey('recovery_codes', $payload); + + $expectedCount = (int)config('auth.recovery_codes.count', 10); + $this->assertCount($expectedCount, $payload['recovery_codes']); + + EntityManager::clear(); + $reloaded = EntityManager::getRepository(User::class)->find($admin->getId()); + $this->assertTrue($reloaded->isTwoFactorEnabled(), '2FA must be enabled on the user after enrollment'); + + $remaining = EntityManager::getRepository(UserRecoveryCode::class) + ->findBy(['user' => $admin->getId(), 'used_at' => null]); + $this->assertCount($expectedCount, $remaining); + } + + public function testEnableTwoFactorRejectsUnavailableMethod(): void + { + // sms_otp is a stub in Phase I (isPhoneNumberVerified() is hardcoded false), + // so enable2FA() must reject it regardless of the requesting user. + $response = $this->enableTwoFactor('sms_otp'); + + $this->assertResponseStatus(412); + } + + public function testEnableTwoFactorRequiresMethod(): void + { + $response = $this->action('POST', 'Api\\UserApiController@enableTwoFactor', [], [], [], []); + + $this->assertResponseStatus(412); + } + + public function testEnableTwoFactorLogsEnrollmentAuditEvent(): void + { + $admin = $this->admin(); + + $this->enableTwoFactor('email_otp'); + + EntityManager::clear(); + $entries = EntityManager::getRepository(TwoFactorAuditLog::class) + ->findBy(['user' => $admin->getId(), 'event_type' => TwoFactorAuditLog::EventEnrollmentChanged]); + $this->assertNotEmpty($entries, 'an enrollment_changed audit entry must be recorded'); + } + + private function enableTwoFactor(string $method) + { + return $this->action('POST', 'Api\\UserApiController@enableTwoFactor', [ + 'method' => $method, + ], [], [], []); + } + + private function admin(): User + { + $user = EntityManager::getRepository(User::class)->findOneBy(['identifier' => self::ADMIN_IDENTIFIER]); + $this->assertInstanceOf(User::class, $user, 'seeded admin user not found'); + return $user; + } + + private function regenerate(string $password) + { + return $this->action('POST', 'Api\\UserApiController@regenerateRecoveryCodes', [ + 'current_password' => $password, + ], [], [], []); + } + + private function createRecoveryCode(User $user, string $plain, bool $used): int + { + $code = new UserRecoveryCode(); + $code->setUser($user); + $code->setCodeHash(Hash::make($plain)); + if ($used) { + $code->markUsed(); + } + EntityManager::persist($code); + EntityManager::flush(); + return $code->getId(); + } +} diff --git a/tests/js/components/recovery_code_display.test.js b/tests/js/components/recovery_code_display.test.js new file mode 100644 index 00000000..03ff60a4 --- /dev/null +++ b/tests/js/components/recovery_code_display.test.js @@ -0,0 +1,53 @@ +import React from 'react'; +import {render, screen, fireEvent} from '@testing-library/react'; +import RecoveryCodeDisplay from '../../../resources/js/components/recovery_code_display'; + +const CODES = ['AAAA-1111', 'BBBB-2222', 'CCCC-3333']; + +describe('RecoveryCodeDisplay', () => { + beforeEach(() => { + Object.assign(navigator, { + clipboard: { + writeText: jest.fn().mockResolvedValue(undefined), + }, + }); + global.URL.createObjectURL = jest.fn(() => 'blob:mock-url'); + global.URL.revokeObjectURL = jest.fn(); + }); + + it('renders every recovery code', () => { + render(); + + CODES.forEach((code) => { + expect(screen.getByText(code)).toBeInTheDocument(); + }); + }); + + it('copies all codes as plain text to the clipboard', async () => { + render(); + + fireEvent.click(screen.getByTestId('copy-codes-button')); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(CODES.join('\n')); + }); + + it('triggers a text file download containing the codes', () => { + const clickSpy = jest.fn(); + const originalCreateElement = document.createElement.bind(document); + const createElementSpy = jest.spyOn(document, 'createElement').mockImplementation((tag) => { + const el = originalCreateElement(tag); + if (tag === 'a') { + el.click = clickSpy; + } + return el; + }); + + render(); + fireEvent.click(screen.getByTestId('download-codes-button')); + + expect(global.URL.createObjectURL).toHaveBeenCalled(); + expect(clickSpy).toHaveBeenCalled(); + + createElementSpy.mockRestore(); + }); +}); diff --git a/tests/js/components/recovery_code_modal.test.js b/tests/js/components/recovery_code_modal.test.js new file mode 100644 index 00000000..8582e7a0 --- /dev/null +++ b/tests/js/components/recovery_code_modal.test.js @@ -0,0 +1,60 @@ +import React from 'react'; +import {render, screen, fireEvent, act} from '@testing-library/react'; +import RecoveryCodeModal from '../../../resources/js/components/recovery_code_modal'; + +const CODES = ['AAAA-1111', 'BBBB-2222']; + +describe('RecoveryCodeModal', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('renders the title, warning banner and codes when open', () => { + render(); + + expect(screen.getByText('Save Your Recovery Codes')).toBeInTheDocument(); + expect(screen.getByTestId('recovery-code-warning')).toBeInTheDocument(); + CODES.forEach((code) => expect(screen.getByText(code)).toBeInTheDocument()); + }); + + it('keeps the acknowledge button disabled until the 5 second delay elapses', () => { + const onAcknowledge = jest.fn(); + render(); + + const ackButton = screen.getByTestId('acknowledge-codes-button'); + expect(ackButton).toBeDisabled(); + + act(() => { + jest.advanceTimersByTime(4000); + }); + expect(ackButton).toBeDisabled(); + + act(() => { + jest.advanceTimersByTime(1000); + }); + expect(ackButton).not.toBeDisabled(); + + fireEvent.click(ackButton); + expect(onAcknowledge).toHaveBeenCalledTimes(1); + }); + + it('cannot be dismissed by pressing Escape', () => { + const onAcknowledge = jest.fn(); + render(); + + fireEvent.keyDown(screen.getByRole('dialog'), {key: 'Escape', code: 'Escape', keyCode: 27}); + + expect(screen.getByText('Save Your Recovery Codes')).toBeInTheDocument(); + expect(onAcknowledge).not.toHaveBeenCalled(); + }); + + it('renders nothing sensitive when there are no codes yet (closed state)', () => { + render(); + + expect(screen.queryByText('Save Your Recovery Codes')).not.toBeInTheDocument(); + }); +}); diff --git a/tests/js/components/recovery_codes_panel.test.js b/tests/js/components/recovery_codes_panel.test.js new file mode 100644 index 00000000..6f1b8392 --- /dev/null +++ b/tests/js/components/recovery_codes_panel.test.js @@ -0,0 +1,105 @@ +import React from 'react'; +import {render, screen, fireEvent} from '@testing-library/react'; +import RecoveryCodesPanel from '../../../resources/js/components/recovery_codes_panel'; +import {regenerateRecoveryCodes} from '../../../resources/js/profile/actions'; + +jest.mock('../../../resources/js/profile/actions'); +jest.mock('sweetalert2', () => jest.fn()); + +// data-testid on a MUI TextField lands on the wrapper
, not the . +const getPasswordInput = () => screen.getByTestId('recovery-codes-current-password').querySelector('input'); + +describe('RecoveryCodesPanel', () => { + beforeEach(() => { + window.sessionStorage.clear(); + jest.clearAllMocks(); + }); + + it('shows the remaining/total count', () => { + render( + + ); + expect(screen.getByTestId('recovery-codes-count')).toHaveTextContent('Recovery Codes: 7 of 10 remaining'); + }); + + it('shows a dismissable low-code warning when remaining is below the threshold', () => { + render( + + ); + expect(screen.getByTestId('low-code-warning')).toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText('dismiss')); + expect(screen.queryByTestId('low-code-warning')).not.toBeInTheDocument(); + }); + + it('does not show the low-code warning when there are enough codes', () => { + render( + + ); + expect(screen.queryByTestId('low-code-warning')).not.toBeInTheDocument(); + }); + + it('respects a custom lowCodeThreshold instead of the default of 3', () => { + // 4 remaining would NOT trigger the default threshold (3), but does with a custom threshold of 5. + render( + + ); + expect(screen.getByTestId('low-code-warning')).toBeInTheDocument(); + }); + + it('respects a custom lower threshold (2 remaining is not below a threshold of 2)', () => { + // With the default threshold (3) this would show the warning; a custom + // threshold of 2 must be honored instead of the hardcoded default. + render( + + ); + expect(screen.queryByTestId('low-code-warning')).not.toBeInTheDocument(); + }); + + it('opens the modal immediately when initialCodes is provided', () => { + const codes = ['AAAA-1111', 'BBBB-2222']; + render( + + ); + expect(screen.getByText('AAAA-1111')).toBeInTheDocument(); + }); + + it('regenerates codes after confirming the current password and opens the modal', async () => { + const newCodes = ['NEW1-CODE', 'NEW2-CODE']; + regenerateRecoveryCodes.mockResolvedValue({response: {recovery_codes: newCodes}}); + + render( + + ); + + fireEvent.click(screen.getByText('Regenerate Codes')); + fireEvent.change(getPasswordInput(), {target: {value: 'my-password'}}); + fireEvent.click(screen.getByTestId('confirm-regenerate-button')); + + expect(regenerateRecoveryCodes).toHaveBeenCalledWith('my-password'); + expect(await screen.findByText('NEW1-CODE')).toBeInTheDocument(); + expect(screen.getByTestId('recovery-codes-count')).toHaveTextContent('Recovery Codes: 2 of 2 remaining'); + }); + + it('shows an error and keeps the existing count when the password is wrong', async () => { + regenerateRecoveryCodes.mockRejectedValue({ + status: 412, + response: {body: {errors: ['current_password is not correct.']}}, + }); + + render( + + ); + + fireEvent.click(screen.getByText('Regenerate Codes')); + fireEvent.change(getPasswordInput(), {target: {value: 'wrong'}}); + fireEvent.click(screen.getByTestId('confirm-regenerate-button')); + + expect(regenerateRecoveryCodes).toHaveBeenCalledWith('wrong'); + await new Promise((resolve) => setImmediate(resolve)); + expect(screen.getByTestId('recovery-codes-count')).toHaveTextContent('Recovery Codes: 7 of 10 remaining'); + }); +}); diff --git a/tests/js/components/two_factor_section.test.js b/tests/js/components/two_factor_section.test.js new file mode 100644 index 00000000..e5e9dfef --- /dev/null +++ b/tests/js/components/two_factor_section.test.js @@ -0,0 +1,48 @@ +import React from 'react'; +import {render, screen, fireEvent} from '@testing-library/react'; +import TwoFactorSection from '../../../resources/js/components/two_factor_section'; +import {enableTwoFactor} from '../../../resources/js/profile/actions'; + +jest.mock('../../../resources/js/profile/actions'); +jest.mock('sweetalert2', () => jest.fn()); + +describe('TwoFactorSection', () => { + beforeEach(() => { + window.sessionStorage.clear(); + jest.clearAllMocks(); + }); + + it('shows the enable button when 2FA is not enabled', () => { + render( + + ); + expect(screen.getByTestId('enable-two-factor-button')).toBeInTheDocument(); + expect(screen.queryByTestId('recovery-codes-count')).not.toBeInTheDocument(); + }); + + it('shows the recovery codes panel when 2FA is already enabled', () => { + render( + + ); + expect(screen.queryByTestId('enable-two-factor-button')).not.toBeInTheDocument(); + expect(screen.getByTestId('recovery-codes-count')).toHaveTextContent('Recovery Codes: 7 of 10 remaining'); + }); + + it('enables 2FA and shows the enrollment codes in the modal', async () => { + const codes = ['AAAA-1111', 'BBBB-2222']; + enableTwoFactor.mockResolvedValue({response: {recovery_codes: codes}}); + + render( + + ); + + fireEvent.click(screen.getByTestId('enable-two-factor-button')); + + expect(enableTwoFactor).toHaveBeenCalledWith('email_otp'); + expect(await screen.findByText('AAAA-1111')).toBeInTheDocument(); + expect(screen.getByTestId('recovery-codes-count')).toHaveTextContent('Recovery Codes: 2 of 2 remaining'); + }); +}); diff --git a/tests/js/setup.js b/tests/js/setup.js index 7b0828bf..dfc57086 100644 --- a/tests/js/setup.js +++ b/tests/js/setup.js @@ -1 +1,12 @@ import '@testing-library/jest-dom'; +import {TextEncoder, TextDecoder} from 'util'; + +// jsdom does not provide these globals; superagent (via base_actions.js) needs +// them transitively (through @paralleldrive/cuid2), even when the module ends +// up auto-mocked by jest.mock() — Jest still evaluates the real module once. +if (typeof global.TextEncoder === 'undefined') { + global.TextEncoder = TextEncoder; +} +if (typeof global.TextDecoder === 'undefined') { + global.TextDecoder = TextDecoder; +} diff --git a/yarn.lock b/yarn.lock index 5c8cb8e6..0b5e712a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1792,7 +1792,7 @@ lz-string "^1.5.0" pretty-format "^27.0.2" -"@testing-library/jest-dom@^5": +"@testing-library/jest-dom@^5.16.5": version "5.17.0" resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-5.17.0.tgz#5e97c8f9a15ccf4656da00fecab505728de81e0c" integrity sha512-ynmNeT7asXyH3aSVv4vvX4Rb+0qjOhdNHnO/3vuZNqPmhDpV/+rCSGwQ7bLcmU2cJ4dvoheIO85LQj0IbJHEtg== @@ -1807,7 +1807,7 @@ lodash "^4.17.15" redent "^3.0.0" -"@testing-library/react@^12": +"@testing-library/react@^12.1.5": version "12.1.5" resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-12.1.5.tgz#bb248f72f02a5ac9d949dea07279095fa577963b" integrity sha512-OfTXCJUFgjd/digLUuPxa0+/3ZxsQmE7ub9kcbW/wi96Bh3o/p5vrETcBGfP17NWPGqeYYl5LTRpwyGoMC4ysg==