Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,9 @@ ILogger<UsersModule> logger
new
{
recoveryCodes = recoveryCodes!.ToArray(),
statusMessage = "Your authenticator app has been verified.",
userEmail = user.Email,
generatedAt = DateTimeOffset.UtcNow.ToString("O"),
statusKey = "authenticator-verified",
}
);
}
Expand Down Expand Up @@ -197,7 +199,9 @@ ILogger<UsersModule> logger
new
{
recoveryCodes = recoveryCodes!.ToArray(),
statusMessage = "You have generated new recovery codes.",
userEmail = user.Email,
generatedAt = DateTimeOffset.UtcNow.ToString("O"),
statusKey = "recovery-codes-generated",
}
);
}
Expand Down
5 changes: 5 additions & 0 deletions modules/Users/src/SimpleModule.Users/Locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@
"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",
"TwoFactor.ResetAuthenticatorApp": "Reset authenticator app",
"TwoFactor.Status.BrowserForgotten": "The current browser has been forgotten. When you login again from this browser you will be prompted for your 2FA code.",
"TwoFactor.Status.2faDisabled": "2FA has been disabled. You can reenable 2FA when you setup an authenticator app.",
"TwoFactor.Status.AuthenticatorVerified": "Your authenticator app has been verified.",
"TwoFactor.Status.RecoveryCodesGenerated": "You have generated new recovery codes.",
"EnableAuthenticator.Title": "Configure authenticator app",
"EnableAuthenticator.Intro": "To use an authenticator app go through the following steps:",
"EnableAuthenticator.Step1": "Download a two-factor authenticator app like Microsoft Authenticator or Google Authenticator.",
Expand Down Expand Up @@ -49,5 +51,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"
}
5 changes: 5 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 @@ -58,13 +61,15 @@ export const UsersKeys = {
OneRecoveryCodeDescription: 'TwoFactor.OneRecoveryCodeDescription',
OneRecoveryCodeLinkText: 'TwoFactor.OneRecoveryCodeLinkText',
OneRecoveryCodeTitle: 'TwoFactor.OneRecoveryCodeTitle',
RecoveryCodesRemaining: 'TwoFactor.RecoveryCodesRemaining',
ResetAuthenticatorApp: 'TwoFactor.ResetAuthenticatorApp',
ResetRecoveryCodes: 'TwoFactor.ResetRecoveryCodes',
SetUpAuthenticatorApp: 'TwoFactor.SetUpAuthenticatorApp',
Status: {
'2faDisabled': 'TwoFactor.Status.2faDisabled',
AuthenticatorVerified: 'TwoFactor.Status.AuthenticatorVerified',
BrowserForgotten: 'TwoFactor.Status.BrowserForgotten',
RecoveryCodesGenerated: 'TwoFactor.Status.RecoveryCodesGenerated',
},
Title: 'TwoFactor.Title',
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@

namespace SimpleModule.Users.Pages.Account;

// Recovery codes are stored hashed: there is no "retrieve codes" endpoint by design.
// The plaintext is only available at generation time (here and on regenerate).
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,58 @@ import { UsersKeys } from '@/Locales/keys';

interface Props {
recoveryCodes: string[];
statusMessage?: string;
userEmail: string;
generatedAt: string;
statusKey?: 'authenticator-verified' | 'recovery-codes-generated';
}

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,
statusKey,
}: Props) {
const { t } = useTranslation('Users');
const printHeader = t(UsersKeys.ShowRecoveryCodes.PrintHeader, {
email: userEmail,
date: generatedAt,
});
const statusKeyToTranslation = {
'authenticator-verified': UsersKeys.TwoFactor.Status.AuthenticatorVerified,
'recovery-codes-generated': UsersKeys.TwoFactor.Status.RecoveryCodesGenerated,
} as const;
const statusMessage = statusKey ? t(statusKeyToTranslation[statusKey]) : null;

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 +70,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