diff --git a/modules/Users/src/SimpleModule.Users/Endpoints/Account/AccountSecurityEndpoint.cs b/modules/Users/src/SimpleModule.Users/Endpoints/Account/AccountSecurityEndpoint.cs index 2c9963be..ce18f746 100644 --- a/modules/Users/src/SimpleModule.Users/Endpoints/Account/AccountSecurityEndpoint.cs +++ b/modules/Users/src/SimpleModule.Users/Endpoints/Account/AccountSecurityEndpoint.cs @@ -108,6 +108,8 @@ ILogger logger new { recoveryCodes = recoveryCodes!.ToArray(), + userEmail = await userManager.GetEmailAsync(user), + generatedAt = DateTimeOffset.UtcNow.ToString("u"), statusMessage = "Your authenticator app has been verified.", } ); @@ -197,6 +199,8 @@ ILogger logger new { recoveryCodes = recoveryCodes!.ToArray(), + userEmail = await userManager.GetEmailAsync(user), + generatedAt = DateTimeOffset.UtcNow.ToString("u"), statusMessage = "You have generated new recovery codes.", } ); diff --git a/modules/Users/src/SimpleModule.Users/Locales/en.json b/modules/Users/src/SimpleModule.Users/Locales/en.json index febdec87..6be328f5 100644 --- a/modules/Users/src/SimpleModule.Users/Locales/en.json +++ b/modules/Users/src/SimpleModule.Users/Locales/en.json @@ -13,6 +13,7 @@ "TwoFactor.FewRecoveryCodesLinkText": "generate a new set of recovery codes", "TwoFactor.ForgetBrowser": "Forget this browser", "TwoFactor.Disable2fa": "Disable 2FA", + "TwoFactor.RecoveryCodesRemaining": "Recovery codes: {count} remaining", "TwoFactor.ResetRecoveryCodes": "Reset recovery codes", "TwoFactor.AddAuthenticatorApp": "Add authenticator app", "TwoFactor.SetUpAuthenticatorApp": "Set up authenticator app", @@ -49,5 +50,8 @@ "ShowRecoveryCodes.Title": "Recovery codes", "ShowRecoveryCodes.WarningTitle": "Put these codes in a safe place.", "ShowRecoveryCodes.WarningDescription": "If you lose your device and don't have the recovery codes you will lose access to your account.", + "ShowRecoveryCodes.DownloadButton": "Download (.txt)", + "ShowRecoveryCodes.PrintButton": "Print", + "ShowRecoveryCodes.PrintHeader": "SimpleModule recovery codes — generated for {email} on {date}", "ShowRecoveryCodes.BackButton": "Back to two-factor authentication" } diff --git a/modules/Users/src/SimpleModule.Users/Locales/keys.ts b/modules/Users/src/SimpleModule.Users/Locales/keys.ts index 45ffbf26..ecc8a5ad 100644 --- a/modules/Users/src/SimpleModule.Users/Locales/keys.ts +++ b/modules/Users/src/SimpleModule.Users/Locales/keys.ts @@ -39,6 +39,9 @@ export const UsersKeys = { }, ShowRecoveryCodes: { BackButton: 'ShowRecoveryCodes.BackButton', + DownloadButton: 'ShowRecoveryCodes.DownloadButton', + PrintButton: 'ShowRecoveryCodes.PrintButton', + PrintHeader: 'ShowRecoveryCodes.PrintHeader', Title: 'ShowRecoveryCodes.Title', WarningDescription: 'ShowRecoveryCodes.WarningDescription', WarningTitle: 'ShowRecoveryCodes.WarningTitle', @@ -59,6 +62,7 @@ export const UsersKeys = { OneRecoveryCodeLinkText: 'TwoFactor.OneRecoveryCodeLinkText', OneRecoveryCodeTitle: 'TwoFactor.OneRecoveryCodeTitle', ResetAuthenticatorApp: 'TwoFactor.ResetAuthenticatorApp', + RecoveryCodesRemaining: 'TwoFactor.RecoveryCodesRemaining', ResetRecoveryCodes: 'TwoFactor.ResetRecoveryCodes', SetUpAuthenticatorApp: 'TwoFactor.SetUpAuthenticatorApp', Status: { diff --git a/modules/Users/src/SimpleModule.Users/Pages/Account/GenerateRecoveryCodesEndpoint.cs b/modules/Users/src/SimpleModule.Users/Pages/Account/GenerateRecoveryCodesEndpoint.cs index ff5ff5a4..ccced98c 100644 --- a/modules/Users/src/SimpleModule.Users/Pages/Account/GenerateRecoveryCodesEndpoint.cs +++ b/modules/Users/src/SimpleModule.Users/Pages/Account/GenerateRecoveryCodesEndpoint.cs @@ -9,6 +9,12 @@ namespace SimpleModule.Users.Pages.Account; +// Recovery codes are stored hashed (like passwords). There is intentionally +// no "get my existing codes" endpoint — once the user closes the +// ShowRecoveryCodes page they can only download/print the fresh set if they +// did so at generation time, or regenerate, which invalidates the old set. +// Do not add a "retrieve codes" code path; the cryptographic contract makes +// it impossible to honor and users would build a false expectation. public class GenerateRecoveryCodesEndpoint : IViewEndpoint { public const string Route = UsersConstants.Routes.GenerateRecoveryCodes; diff --git a/modules/Users/src/SimpleModule.Users/Pages/Account/ShowRecoveryCodes.tsx b/modules/Users/src/SimpleModule.Users/Pages/Account/ShowRecoveryCodes.tsx index 9cbbd6b0..bfac71ab 100644 --- a/modules/Users/src/SimpleModule.Users/Pages/Account/ShowRecoveryCodes.tsx +++ b/modules/Users/src/SimpleModule.Users/Pages/Account/ShowRecoveryCodes.tsx @@ -5,14 +5,49 @@ import { UsersKeys } from '@/Locales/keys'; interface Props { recoveryCodes: string[]; + userEmail?: string | null; + generatedAt?: string | null; statusMessage?: string; } -export default function ShowRecoveryCodes({ recoveryCodes, statusMessage }: Props) { +function downloadCodes(codes: string[], header: string, fileName = 'simplemodule-recovery-codes.txt') { + const body = [header, '', ...codes].join('\n'); + const blob = new Blob([body], { type: 'text/plain;charset=utf-8' }); + 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 default function ShowRecoveryCodes({ + recoveryCodes, + userEmail, + generatedAt, + statusMessage, +}: Props) { const { t } = useTranslation('Users'); + const printHeader = t(UsersKeys.ShowRecoveryCodes.PrintHeader, { + email: userEmail ?? '', + date: generatedAt ?? new Date().toISOString(), + }); return ( + +

{t(UsersKeys.ShowRecoveryCodes.Title)}

{statusMessage && ( @@ -26,25 +61,36 @@ export default function ShowRecoveryCodes({ recoveryCodes, statusMessage }: Prop {t(UsersKeys.ShowRecoveryCodes.WarningDescription)} -
- {recoveryCodes.map((code) => ( - - {code} - - ))} +
+

{printHeader}

+
+ {recoveryCodes.map((code) => ( + + {code} + + ))} +
- +
+ + + +
); } diff --git a/modules/Users/src/SimpleModule.Users/Pages/Account/TwoFactorAuthentication.tsx b/modules/Users/src/SimpleModule.Users/Pages/Account/TwoFactorAuthentication.tsx index f7f426c2..e0931258 100644 --- a/modules/Users/src/SimpleModule.Users/Pages/Account/TwoFactorAuthentication.tsx +++ b/modules/Users/src/SimpleModule.Users/Pages/Account/TwoFactorAuthentication.tsx @@ -45,6 +45,14 @@ export default function TwoFactorAuthentication({ {is2faEnabled && ( <> + {recoveryCodesLeft >= 4 && ( +

+ {t(UsersKeys.TwoFactor.RecoveryCodesRemaining, { + count: String(recoveryCodesLeft), + })} +

+ )} + {recoveryCodesLeft === 0 && ( {t(UsersKeys.TwoFactor.NoRecoveryCodesTitle)}