Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ ILogger<UsersModule> logger
new
{
recoveryCodes = recoveryCodes!.ToArray(),
userEmail = await userManager.GetEmailAsync(user),
generatedAt = DateTimeOffset.UtcNow.ToString("u"),
statusMessage = "Your authenticator app has been verified.",
}
);
Expand Down Expand Up @@ -197,6 +199,8 @@ ILogger<UsersModule> logger
new
{
recoveryCodes = recoveryCodes!.ToArray(),
userEmail = await userManager.GetEmailAsync(user),
generatedAt = DateTimeOffset.UtcNow.ToString("u"),
statusMessage = "You have generated new recovery codes.",
}
);
Expand Down
4 changes: 4 additions & 0 deletions modules/Users/src/SimpleModule.Users/Locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
}
4 changes: 4 additions & 0 deletions modules/Users/src/SimpleModule.Users/Locales/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<ManageLayout activePage="TwoFactorAuthentication">
<style>{`
@media print {
body * { visibility: hidden; }
#recovery-codes-print, #recovery-codes-print * { visibility: visible; }
#recovery-codes-print {
position: absolute; left: 0; top: 0; width: 100%;
color: #000; background: #fff; padding: 1in; font-family: ui-monospace, monospace;
}
}
`}</style>

<h3 className="text-lg font-semibold mb-3 sm:mb-4">{t(UsersKeys.ShowRecoveryCodes.Title)}</h3>

{statusMessage && (
Expand All @@ -26,25 +61,36 @@ export default function ShowRecoveryCodes({ recoveryCodes, statusMessage }: Prop
<AlertDescription>{t(UsersKeys.ShowRecoveryCodes.WarningDescription)}</AlertDescription>
</Alert>

<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 mb-4 sm:mb-6">
{recoveryCodes.map((code) => (
<code
key={code}
className="block bg-surface-raised px-3 py-2 rounded-lg text-sm text-center select-all"
>
{code}
</code>
))}
<div id="recovery-codes-print">
<p className="hidden print:block mb-4 text-sm">{printHeader}</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 mb-4 sm:mb-6">
{recoveryCodes.map((code) => (
<code
key={code}
className="block bg-surface-raised px-3 py-2 rounded-lg text-sm text-center select-all print:bg-transparent print:text-black"
>
{code}
</code>
))}
</div>
</div>

<Button
variant="outline"
onClick={() => {
window.location.href = '/Identity/Account/Manage/TwoFactorAuthentication';
}}
>
{t(UsersKeys.ShowRecoveryCodes.BackButton)}
</Button>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
onClick={() => {
window.location.href = '/Identity/Account/Manage/TwoFactorAuthentication';
}}
>
{t(UsersKeys.ShowRecoveryCodes.BackButton)}
</Button>
<Button variant="outline" onClick={() => downloadCodes(recoveryCodes, printHeader)}>
{t(UsersKeys.ShowRecoveryCodes.DownloadButton)}
</Button>
<Button variant="outline" onClick={() => window.print()}>
{t(UsersKeys.ShowRecoveryCodes.PrintButton)}
</Button>
</div>
</ManageLayout>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@ export default function TwoFactorAuthentication({

{is2faEnabled && (
<>
{recoveryCodesLeft >= 4 && (
<p className="mb-4 text-sm text-text-muted">
{t(UsersKeys.TwoFactor.RecoveryCodesRemaining, {
count: String(recoveryCodesLeft),
})}
</p>
)}

{recoveryCodesLeft === 0 && (
<Alert variant="danger" className="mb-4">
<AlertTitle>{t(UsersKeys.TwoFactor.NoRecoveryCodesTitle)}</AlertTitle>
Expand Down
Loading