Skip to content

Commit bcd0330

Browse files
committed
feat: warn on low recovery codes after MFA recovery login
CU-86ba2zp66 / sds/idp-mfa.md §4.10.3, §4.11 step 5 require a dismissable low-code warning after a successful MFA login, but it was only wired into the profile page - a user who burns codes at login never saw it unless they happened to visit their profile. verify2FARecovery now returns recovery_codes_remaining and the configured low threshold; login.js holds the post-login redirect and shows a dismissable banner when the count is low, before navigating away. The sessionStorage dismissal key is shared with the profile page's RecoveryCodesPanel via a new shared module so dismissing in either place suppresses it everywhere for the rest of the session.
1 parent b729e55 commit bcd0330

4 files changed

Lines changed: 76 additions & 10 deletions

File tree

app/Http/Controllers/UserController.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -927,7 +927,14 @@ public function verify2FARecovery()
927927
// See verify2FA() for rationale: return the destination as data so a real
928928
// top-level navigation (not this XHR) performs any cross-origin hop.
929929
$redirect = $this->login_strategy->postLogin();
930-
return $this->ok(['redirect_url' => $redirect->getTargetUrl()]);
930+
return $this->ok([
931+
'redirect_url' => $redirect->getTargetUrl(),
932+
// CU-86ba2zp66 / sds/idp-mfa.md §4.10.3, §4.11 step 5: the login page
933+
// must be able to warn the user when they've just burned into their
934+
// last few recovery codes, since it may be their only way back in.
935+
'recovery_codes_remaining' => $this->recovery_code_service->countUnusedRecoveryCodes($user),
936+
'recovery_codes_low_threshold' => (int) config('auth.recovery_codes.low_threshold', 3),
937+
]);
931938
} catch (ValidationException $ex) {
932939
Log::warning($ex);
933940
return $this->error412($ex->getMessages());

resources/js/components/recovery_codes_panel.js

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,17 @@ import CloseIcon from "@material-ui/icons/Close";
1010
import {regenerateRecoveryCodes} from "../profile/actions";
1111
import {handleErrorResponse} from "../utils";
1212
import RecoveryCodeModal from "./recovery_code_modal";
13+
import {
14+
RECOVERY_CODES_LOW_WARNING_DISMISSED_KEY,
15+
DEFAULT_RECOVERY_CODES_LOW_THRESHOLD,
16+
} from "../shared/recovery_codes";
1317

1418
import styles from "./recovery_codes.module.scss";
1519

16-
const DEFAULT_LOW_CODE_THRESHOLD = 3;
17-
const LOW_CODE_WARNING_DISMISSED_KEY = "recovery_codes_low_warning_dismissed";
18-
1920
const RecoveryCodesPanel = ({
2021
recoveryCodesRemaining,
2122
recoveryCodesTotal,
22-
lowCodeThreshold = DEFAULT_LOW_CODE_THRESHOLD,
23+
lowCodeThreshold = DEFAULT_RECOVERY_CODES_LOW_THRESHOLD,
2324
email,
2425
initialCodes = null
2526
}) => {
@@ -30,7 +31,7 @@ const RecoveryCodesPanel = ({
3031
const [total, setTotal] = useState(recoveryCodesTotal);
3132
const [codes, setCodes] = useState(initialCodes);
3233
const [warningDismissed, setWarningDismissed] = useState(
33-
sessionStorage.getItem(LOW_CODE_WARNING_DISMISSED_KEY) === "1"
34+
sessionStorage.getItem(RECOVERY_CODES_LOW_WARNING_DISMISSED_KEY) === "1"
3435
);
3536

3637
const handleRegenerate = () => {
@@ -53,7 +54,7 @@ const RecoveryCodesPanel = ({
5354
};
5455

5556
const dismissLowCodeWarning = () => {
56-
sessionStorage.setItem(LOW_CODE_WARNING_DISMISSED_KEY, "1");
57+
sessionStorage.setItem(RECOVERY_CODES_LOW_WARNING_DISMISSED_KEY, "1");
5758
setWarningDismissed(true);
5859
};
5960

resources/js/login/login.js

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,13 @@ import EmailErrorActions from "./components/email_error_actions";
3131
import ThirdPartyIdentityProviders from "./components/third_party_identity_providers";
3232
import TwoFactorForm from "./components/two_factor_form";
3333
import RecoveryCodeForm from "./components/recovery_code_form";
34+
import {
35+
RECOVERY_CODES_LOW_WARNING_DISMISSED_KEY,
36+
DEFAULT_RECOVERY_CODES_LOW_THRESHOLD,
37+
} from "../shared/recovery_codes";
3438

3539
import styles from "./login.module.scss";
40+
import recoveryCodesStyles from "../components/recovery_codes.module.scss";
3641
import "./third_party_identity_providers.scss";
3742
import {
3843
FLOW,
@@ -93,6 +98,11 @@ class LoginPage extends React.Component {
9398
// change writes otp_lifetime atomically with flow, so props.otpLifetime
9499
// is only ever missing when there's no pending OTP to show a countdown for.
95100
passwordlessLifetime: props.otpLifetime ?? null,
101+
// Set once a recovery-code login succeeds with a low remaining count, so
102+
// the redirect can be held until the user acknowledges the warning -
103+
// this is the only point where the SPA still controls the page (see
104+
// onVerifyRecovery()/onContinueAfterLowRecoveryCodes() below).
105+
lowRecoveryCodesWarning: null,
96106
};
97107

98108
if (props.authError != "" && !this.state.user_fullname) {
@@ -130,6 +140,7 @@ class LoginPage extends React.Component {
130140
this.onVerify2FA = this.onVerify2FA.bind(this);
131141
this.onResend2FA = this.onResend2FA.bind(this);
132142
this.onVerifyRecovery = this.onVerifyRecovery.bind(this);
143+
this.onContinueAfterLowRecoveryCodes = this.onContinueAfterLowRecoveryCodes.bind(this);
133144
this.onUseRecovery = this.onUseRecovery.bind(this);
134145
this.onBackToOtp = this.onBackToOtp.bind(this);
135146
this.resetToPasswordFlow = this.resetToPasswordFlow.bind(this);
@@ -540,17 +551,38 @@ class LoginPage extends React.Component {
540551

541552
verifyRecoveryCode(recoveryCode, this.props.token).then(
542553
(payload) => {
543-
// See onVerify2FA() for rationale.
544554
const { response } = payload;
545-
window.location.href =
555+
const redirectUrl =
546556
(response && response.redirect_url) || window.location.href;
557+
const remaining = response && response.recovery_codes_remaining;
558+
const threshold =
559+
(response && response.recovery_codes_low_threshold) ??
560+
DEFAULT_RECOVERY_CODES_LOW_THRESHOLD;
561+
const alreadyDismissed =
562+
sessionStorage.getItem(RECOVERY_CODES_LOW_WARNING_DISMISSED_KEY) === "1";
563+
564+
if (typeof remaining === "number" && remaining < threshold && !alreadyDismissed) {
565+
this.setState({
566+
...this.state,
567+
lowRecoveryCodesWarning: { remaining, redirectUrl },
568+
});
569+
return;
570+
}
571+
572+
// See onVerify2FA() for rationale on using a real top-level navigation.
573+
window.location.href = redirectUrl;
547574
},
548575
(error) => {
549576
this.handleMfaError(error, "recovery");
550577
},
551578
);
552579
}
553580

581+
onContinueAfterLowRecoveryCodes() {
582+
sessionStorage.setItem(RECOVERY_CODES_LOW_WARNING_DISMISSED_KEY, "1");
583+
window.location.href = this.state.lowRecoveryCodesWarning.redirectUrl;
584+
}
585+
554586
onUseRecovery() {
555587
this.setState({
556588
...this.state,
@@ -833,7 +865,7 @@ class LoginPage extends React.Component {
833865
onVerify={this.onVerify2FA}
834866
/>
835867
)}
836-
{showRecoveryForm && (
868+
{showRecoveryForm && !this.state.lowRecoveryCodesWarning && (
837869
<RecoveryCodeForm
838870
recoveryCode={this.state.recoveryCode}
839871
recoveryError={this.state.errors.recovery}
@@ -844,6 +876,27 @@ class LoginPage extends React.Component {
844876
onCancel={this.resetToPasswordFlow}
845877
/>
846878
)}
879+
{showRecoveryForm && this.state.lowRecoveryCodesWarning && (
880+
<div
881+
className={recoveryCodesStyles.low_code_warning}
882+
data-testid="low-recovery-codes-warning"
883+
>
884+
<Typography variant="body2">
885+
You have {this.state.lowRecoveryCodesWarning.remaining} recovery
886+
code{this.state.lowRecoveryCodesWarning.remaining === 1 ? "" : "s"} left.
887+
Regenerate them from your profile after signing in to avoid getting
888+
locked out.
889+
</Typography>
890+
<Button
891+
variant="contained"
892+
color="primary"
893+
onClick={this.onContinueAfterLowRecoveryCodes}
894+
data-testid="continue-after-low-recovery-codes"
895+
>
896+
Continue
897+
</Button>
898+
</div>
899+
)}
847900
{isPasswordFlow && (
848901
// proceed to ask for password ( 2nd step )
849902
<div data-testid="password-form">
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
// Shared between the profile page's RecoveryCodesPanel and the login page's
2+
// post-MFA-recovery-login warning, so dismissing the low-code warning in
3+
// either place suppresses it everywhere else for the rest of the session.
4+
export const RECOVERY_CODES_LOW_WARNING_DISMISSED_KEY = "recovery_codes_low_warning_dismissed";
5+
export const DEFAULT_RECOVERY_CODES_LOW_THRESHOLD = 3;

0 commit comments

Comments
 (0)