Skip to content

Commit 807befd

Browse files
romanetarclaude
andauthored
Recovery code login flow UI (#151)
* Feature | Recovery code login flow UI (CU-86ba2zp4f) Close the remaining gaps in the recovery-code MFA login mode. - onBackToOtp / onUseRecovery clear recoveryCode and errors.recovery along with the mode switch, so an abandoned attempt is not re-shown when the user toggles back into recovery mode. - Recovery field drops autoComplete="one-time-code": that hint makes the OS offer the e-mailed OTP in the recovery field, which is the wrong credential and the OTP/recovery confusion risk called out in the ticket. Added a format hint and copy that distinguishes it from the e-mailed code. Tests: - New recovery-code-form.test.js: autocomplete, format hint, disabled states, inline error, submit, back vs cancel, raw value handed to parent. - login.mfa.test.js: mode switching + state cleanup, input normalization, empty/in-flight submits, success redirect, low-codes warning (and its already-dismissed variant), invalid/used code, mfa_session_expired, mfa_rate_limit. - E2E TS-005 fixed (it filled a 16-char code that can never exist) and now asserts the dash never reaches the endpoint; new TS-009 back-to-OTP, TS-010 invalid/used code, TS-011 recovery rate limit, TS-012 recovery session expiry. CI seeds mfa-ts-009..012 for them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore: seed mfa-ts-009..012 in the push front-end workflow too The MFA e2e suite gives every TS-* test its own account because a real login burns that user's own OTP rate-limit window. TS-009..TS-012 were added with the seed loop widened only in pull_request_frontend_tests.yml, so "Front End Tests On Push" logged in as users that do not exist and the four new tests timed out waiting for the password step. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep the recovery code out of the request URL verifyRecoveryCode() posted through postRawRequest(), which copies every param onto the query string in addition to the body (base_actions.js:71). That writes the recovery code - a credential that completes a login on its own - into any access log along the path. #146 hit the same trap with current_password and added the body-only postRawRequestFull() for it; switch this call to it as well. TS-005 asserted the code off the query string, which encoded the leak as the expected contract. It now asserts the body carries the code and the query string does not, so the fix cannot silently regress. Also from review: - the "all 8 tests" comment in the MFA spec had gone stale at 12 tests; reworded so it does not track a count, and it now states the seed loop lives in BOTH workflow files (the divergence that broke push CI). - onBackToOtp()'s comment justified its reset with a clean-field-on-reentry guarantee that onUseRecovery() already provides; state the real reason, which is not holding an unspent credential in component state. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent c19f19a commit 807befd

9 files changed

Lines changed: 453 additions & 16 deletions

File tree

‎.github/workflows/pull_request_frontend_tests.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ jobs:
103103
php artisan db:seed --force
104104
php artisan idp:create-super-admin test@test.com '1Qaz2wsx!'
105105
php artisan idp:create-raw-user e2e@test.com '1Qaz2wsx!'
106-
for i in 001 002 003 004 005 006 007 008; do
106+
for i in 001 002 003 004 005 006 007 008 009 010 011 012; do
107107
php artisan idp:create-super-admin "mfa-ts-$i@test.com" '1Qaz2wsx!'
108108
done
109109
php artisan idp:create-super-admin mfa-oauth2@test.com '1Qaz2wsx!'

‎.github/workflows/push_frontend_tests.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ jobs:
104104
php artisan db:seed --force
105105
php artisan idp:create-super-admin test@test.com '1Qaz2wsx!'
106106
php artisan idp:create-raw-user e2e@test.com '1Qaz2wsx!'
107-
for i in 001 002 003 004 005 006 007 008; do
107+
for i in 001 002 003 004 005 006 007 008 009 010 011 012; do
108108
php artisan idp:create-super-admin "mfa-ts-$i@test.com" '1Qaz2wsx!'
109109
done
110110
php artisan idp:create-super-admin mfa-oauth2@test.com '1Qaz2wsx!'

‎resources/js/login/actions.js‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {postRawRequest} from '../base_actions'
1+
import {postRawRequest, postRawRequestFull} from '../base_actions'
22

33
export const verifyAccount = (email, token) => {
44

@@ -51,7 +51,11 @@ export const verifyRecoveryCode = (recoveryCode, token) => {
5151
recovery_code: recoveryCode
5252
};
5353

54-
return postRawRequest(window.RECOVERY_2FA_ENDPOINT)(params, {'X-CSRF-TOKEN': token});
54+
// postRawRequestFull(), not postRawRequest(): the latter also copies every
55+
// param onto the query string, which would write the recovery code - a
56+
// credential that completes a login on its own - into every access log it
57+
// passes through. Same reasoning as the current_password fix in #146.
58+
return postRawRequestFull(window.RECOVERY_2FA_ENDPOINT)(params, {'X-CSRF-TOKEN': token});
5559
}
5660

5761
export const cancelLogin = (token) => {

‎resources/js/login/components/recovery_code_form.js‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ const RecoveryCodeForm = ({
3434
<form onSubmit={handleSubmit} target="_self" className={styles.otp_form} data-testid="recovery-form">
3535
<div className={styles.subtitle}>Enter a recovery code</div>
3636
<p className={styles.info_message}>
37-
Enter one of the recovery codes you saved when you enabled two-step verification.
37+
This is not the code we e-mailed you. Enter one of the recovery codes you saved
38+
when you enabled two-step verification.
3839
</p>
3940
<TextField
4041
id="recovery_code"
@@ -46,7 +47,10 @@ const RecoveryCodeForm = ({
4647
fullWidth
4748
autoFocus={true}
4849
label="Recovery code"
49-
autoComplete="one-time-code"
50+
helperText="8 characters, shown as ABCD-1234. The dash is optional."
51+
// Deliberately not "one-time-code": that hint makes the OS offer the
52+
// e-mailed OTP here, which is the wrong credential for this field.
53+
autoComplete="off"
5054
disabled={disableInput}
5155
onChange={onRecoveryCodeChange}
5256
error={!!recoveryError}
@@ -69,7 +73,8 @@ const RecoveryCodeForm = ({
6973
<div className={styles.footer_instructions}>
7074
<hr className={styles.separator}/>
7175
<div className={styles.box}>
72-
<Link href="#" onClick={handleBack} variant="body2" target="_self">
76+
<Link href="#" onClick={handleBack} variant="body2" target="_self"
77+
data-testid="back-to-otp-link">
7378
Back to verification code
7479
</Link>
7580
{" · "}

‎resources/js/login/login.js‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -587,15 +587,21 @@ class LoginPage extends React.Component {
587587
this.setState({
588588
...this.state,
589589
authFlow: FLOW.RECOVERY,
590+
recoveryCode: "",
590591
errors: { ...this.state.errors, recovery: "" },
591592
});
592593
}
593594

594595
onBackToOtp() {
596+
// Drop the abandoned recovery code as we leave the mode so a credential the
597+
// user chose not to spend is not kept in component state for the rest of the
598+
// session. A clean field on re-entry is already guaranteed by onUseRecovery();
599+
// this is about not holding the value, not about what the next render shows.
595600
this.setState({
596601
...this.state,
597602
authFlow: FLOW.MFA,
598-
errors: { ...this.state.errors, twofactor: "" },
603+
recoveryCode: "",
604+
errors: { ...this.state.errors, twofactor: "", recovery: "" },
599605
});
600606
}
601607

‎tests/e2e/pages/LoginPage.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ export class LoginPage {
2121
readonly useRecoveryLink: Locator;
2222
// Recovery code step
2323
readonly recoveryForm: Locator;
24+
readonly recoveryCodeInput: Locator;
25+
readonly backToOtpLink: Locator;
2426

2527
constructor(page: Page) {
2628
this.page = page;
@@ -38,6 +40,8 @@ export class LoginPage {
3840
this.cancelLink = page.locator('[data-testid="cancel-link"]');
3941
this.useRecoveryLink = page.locator('[data-testid="use-recovery-link"]');
4042
this.recoveryForm = page.locator('[data-testid="recovery-form"]');
43+
this.recoveryCodeInput = page.locator('#recovery_code');
44+
this.backToOtpLink = page.locator('[data-testid="back-to-otp-link"]');
4145
}
4246

4347
async goto() {

‎tests/e2e/tests/auth/login-mfa-flow.spec.ts‎

Lines changed: 118 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,27 @@ import type { Page } from '@playwright/test';
66
// param onto the URL as a query string (in addition to the body), so the real
77
// request is "<path>?otp_value=...&method=..." - a glob without the trailing
88
// wildcard requires an exact end-of-string match and silently never fires.
9+
// The recovery endpoint is the exception: it posts through postRawRequestFull(),
10+
// which is body-only, so its URL carries no query string ('**' still matches).
911
const VERIFY_URL = '**/auth/login/2fa/verify**';
1012
const RESEND_URL = '**/auth/login/2fa/resend**';
1113
const RECOVERY_URL = '**/auth/login/2fa/recovery**';
1214
const CANCEL_URL = '**/auth/login/reset**';
1315

14-
// Each TS-* test gets its own MFA-enforced super-admin (mfa-ts-NNN@test.com,
15-
// seeded by CI via idp:create-super-admin - see pull_request_frontend_tests.yml).
16+
// Each TS-* test gets its own MFA-enforced super-admin (mfa-ts-NNN@test.com),
17+
// seeded by CI via idp:create-super-admin in BOTH pull_request_frontend_tests.yml
18+
// and push_frontend_tests.yml - adding a TS-NNN here means widening the seed loop
19+
// in both, or the new test logs in as a user that does not exist.
1620
// A real login issues a real OTP challenge and counts against that user's own
1721
// two_factor.rate_limit.max_otp_requests window, so sharing one fixed account
18-
// across all 8 tests would exhaust the limit well before the suite finishes.
22+
// across the whole suite would exhaust the limit well before it finishes.
1923
const MFA_USER_PASSWORD = '1Qaz2wsx!';
2024

25+
// Recovery codes are generated as 8 chars from [A-Z0-9] and shown as XXXX-XXXX
26+
// (RecoveryCodeService::regenerateCodesForUser), but hashed without the dash.
27+
const RECOVERY_CODE_AS_DISPLAYED = 'ABCD-1234';
28+
const RECOVERY_CODE_NORMALIZED = 'ABCD1234';
29+
2130
function mfaUserEmailFor(testTitle: string): string {
2231
const match = testTitle.match(/TS-(\d+)/);
2332
if (!match) {
@@ -127,7 +136,7 @@ test.describe('MFA Login Flow', () => {
127136
});
128137

129138
// TS-005 ─────────────────────────────────────────────────────────────────
130-
test('TS-005: use recovery code — recovery form shown and API called',
139+
test('TS-005: use recovery code — recovery form shown and normalized code posted',
131140
async ({ loginPage, page }) => {
132141
await page.route(RECOVERY_URL, route =>
133142
route.fulfill({ status: 200, contentType: 'application/json', body: '{}' })
@@ -137,13 +146,21 @@ test.describe('MFA Login Flow', () => {
137146
await expect(loginPage.recoveryForm).toBeVisible();
138147
await expect(loginPage.twoFactorForm).not.toBeVisible();
139148

140-
await page.locator('#recovery_code').fill('ABCD-1234-EFGH-5678');
149+
// Typed exactly as the code is displayed to the user (XXXX-XXXX); the
150+
// separator is presentational only and must never reach the endpoint.
151+
await loginPage.recoveryCodeInput.fill(RECOVERY_CODE_AS_DISPLAYED);
152+
await expect(loginPage.recoveryCodeInput).toHaveValue(RECOVERY_CODE_NORMALIZED);
141153

142-
const [response] = await Promise.all([
143-
page.waitForResponse(RECOVERY_URL),
154+
const [request] = await Promise.all([
155+
page.waitForRequest(RECOVERY_URL),
144156
loginPage.verifyButton.click(),
145157
]);
146-
expect(response.status()).toBe(200);
158+
159+
// The code travels in the body only. verifyRecoveryCode() posts through
160+
// postRawRequestFull() precisely so it never reaches the query string,
161+
// where access logs would capture it - assert both halves of that.
162+
expect(request.postDataJSON()).toMatchObject({ recovery_code: RECOVERY_CODE_NORMALIZED });
163+
expect(new URL(request.url()).searchParams.get('recovery_code')).toBeNull();
147164
});
148165

149166
// TS-006 ─────────────────────────────────────────────────────────────────
@@ -210,4 +227,97 @@ test.describe('MFA Login Flow', () => {
210227
await expect(loginPage.errorLabel).toBeVisible();
211228
await expect(loginPage.errorLabel).toContainText('Too many attempts');
212229
});
230+
231+
// TS-009 ─────────────────────────────────────────────────────────────────
232+
test('TS-009: back to verification code — OTP mode restored with a clean recovery field',
233+
async ({ loginPage }) => {
234+
await loginPage.useRecoveryLink.click();
235+
await expect(loginPage.recoveryForm).toBeVisible();
236+
237+
await loginPage.recoveryCodeInput.fill(RECOVERY_CODE_NORMALIZED);
238+
await loginPage.backToOtpLink.click();
239+
240+
// The OTP flow must be intact — same form, still able to submit a code.
241+
await expect(loginPage.twoFactorForm).toBeVisible();
242+
await expect(loginPage.recoveryForm).not.toBeVisible();
243+
await expect(loginPage.passwordForm).not.toBeVisible();
244+
245+
// Re-entering recovery mode must not resurrect the abandoned code.
246+
await loginPage.useRecoveryLink.click();
247+
await expect(loginPage.recoveryCodeInput).toHaveValue('');
248+
});
249+
250+
// TS-010 ─────────────────────────────────────────────────────────────────
251+
test('TS-010: invalid/used recovery code — inline error, still in the recovery form',
252+
async ({ loginPage, page }) => {
253+
// A used code fails exactly like an unknown one: the backend answers
254+
// mfa_invalid_recovery for both (AbstractMFAChallengeStrategy).
255+
await page.route(RECOVERY_URL, route =>
256+
route.fulfill({
257+
status: 401,
258+
contentType: 'application/json',
259+
body: JSON.stringify({ error_code: 'mfa_invalid_recovery' }),
260+
})
261+
);
262+
263+
await loginPage.useRecoveryLink.click();
264+
await loginPage.recoveryCodeInput.fill(RECOVERY_CODE_NORMALIZED);
265+
await loginPage.verifyButton.click();
266+
267+
await expect(loginPage.errorLabel).toBeVisible();
268+
await expect(loginPage.errorLabel).toContainText('Invalid recovery code');
269+
// The MFA flow must not be abandoned on a bad code.
270+
await expect(loginPage.recoveryForm).toBeVisible();
271+
await expect(loginPage.passwordForm).not.toBeVisible();
272+
});
273+
274+
// TS-011 ─────────────────────────────────────────────────────────────────
275+
test('TS-011: recovery rate limit — 429 inline error shown',
276+
async ({ loginPage, page }) => {
277+
await page.route(RECOVERY_URL, route =>
278+
route.fulfill({
279+
status: 429,
280+
contentType: 'application/json',
281+
body: JSON.stringify({
282+
error_code: 'mfa_rate_limit',
283+
error_message: 'Too many attempts. Please try again later.',
284+
}),
285+
})
286+
);
287+
288+
await loginPage.useRecoveryLink.click();
289+
await loginPage.recoveryCodeInput.fill(RECOVERY_CODE_NORMALIZED);
290+
await loginPage.verifyButton.click();
291+
292+
await expect(loginPage.errorLabel).toBeVisible();
293+
await expect(loginPage.errorLabel).toContainText('Too many attempts');
294+
await expect(loginPage.recoveryForm).toBeVisible();
295+
});
296+
297+
// TS-012 ─────────────────────────────────────────────────────────────────
298+
test('TS-012: recovery session expired — back to password form with warning snackbar',
299+
async ({ loginPage, page }) => {
300+
await page.route(RECOVERY_URL, route =>
301+
route.fulfill({
302+
status: 401,
303+
contentType: 'application/json',
304+
body: JSON.stringify({ error_code: 'mfa_session_expired' }),
305+
})
306+
);
307+
// resetToPasswordFlow() fires cancelLogin() in the background; absorb it.
308+
await page.route(CANCEL_URL, route =>
309+
route.fulfill({ status: 200, contentType: 'application/json', body: '{}' })
310+
);
311+
312+
await loginPage.useRecoveryLink.click();
313+
await loginPage.recoveryCodeInput.fill(RECOVERY_CODE_NORMALIZED);
314+
await loginPage.verifyButton.click();
315+
316+
await expect(loginPage.recoveryForm).not.toBeVisible();
317+
await expect(loginPage.passwordForm).toBeVisible();
318+
319+
const snackbar = page.locator('[role="alert"]');
320+
await expect(snackbar).toBeVisible();
321+
await expect(snackbar).toContainText('session has expired');
322+
});
213323
});
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import React from 'react';
2+
import { render, screen, fireEvent } from '@testing-library/react';
3+
import RecoveryCodeForm from '../../../../resources/js/login/components/recovery_code_form';
4+
5+
const baseProps = {
6+
recoveryCode: 'ABCD1234',
7+
recoveryError: '',
8+
disableInput: false,
9+
onRecoveryCodeChange: jest.fn(),
10+
onVerify: jest.fn(),
11+
onBackToOtp: jest.fn(),
12+
onCancel: jest.fn(),
13+
};
14+
15+
describe('RecoveryCodeForm', () => {
16+
17+
beforeEach(() => jest.clearAllMocks());
18+
19+
it('does not advertise the field as a one-time-code slot', () => {
20+
// "one-time-code" would make the OS offer the e-mailed OTP here — the
21+
// wrong credential for this field, and the OTP/recovery confusion risk.
22+
render(<RecoveryCodeForm {...baseProps} />);
23+
expect(document.getElementById('recovery_code')).toHaveAttribute('autocomplete', 'off');
24+
});
25+
26+
it('renders the expected-format hint', () => {
27+
render(<RecoveryCodeForm {...baseProps} />);
28+
expect(screen.getByText(/8 characters, shown as ABCD-1234/)).toBeInTheDocument();
29+
});
30+
31+
it('VERIFY button is disabled when the code is empty', () => {
32+
render(<RecoveryCodeForm {...baseProps} recoveryCode="" />);
33+
expect(screen.getByTestId('verify-button')).toBeDisabled();
34+
});
35+
36+
it('VERIFY button is disabled while a submit is in flight', () => {
37+
render(<RecoveryCodeForm {...baseProps} disableInput={true} />);
38+
expect(screen.getByTestId('verify-button')).toBeDisabled();
39+
});
40+
41+
it('error paragraph renders when recoveryError is non-empty', () => {
42+
const msg = 'Invalid recovery code. Please try again.';
43+
render(<RecoveryCodeForm {...baseProps} recoveryError={msg} />);
44+
expect(screen.getByTestId('error-label')).toHaveTextContent(msg);
45+
});
46+
47+
it('submitting the form calls onVerify without navigating', () => {
48+
render(<RecoveryCodeForm {...baseProps} />);
49+
fireEvent.submit(screen.getByTestId('recovery-form'));
50+
expect(baseProps.onVerify).toHaveBeenCalledTimes(1);
51+
});
52+
53+
it('"Back to verification code" calls onBackToOtp', () => {
54+
render(<RecoveryCodeForm {...baseProps} />);
55+
fireEvent.click(screen.getByTestId('back-to-otp-link'));
56+
expect(baseProps.onBackToOtp).toHaveBeenCalledTimes(1);
57+
expect(baseProps.onCancel).not.toHaveBeenCalled();
58+
});
59+
60+
it('"Cancel" calls onCancel', () => {
61+
render(<RecoveryCodeForm {...baseProps} />);
62+
fireEvent.click(screen.getByTestId('cancel-link'));
63+
expect(baseProps.onCancel).toHaveBeenCalledTimes(1);
64+
expect(baseProps.onBackToOtp).not.toHaveBeenCalled();
65+
});
66+
67+
it('typing in the field reports the raw value to the parent', () => {
68+
// Normalization lives in LoginPage.onRecoveryCodeChange(); the form must
69+
// hand over the untouched event so that stays the single source of truth.
70+
// Read the value inside the handler: the field is controlled, so React
71+
// resets the DOM node back to the (unchanged) prop before the assertion runs.
72+
let seen = null;
73+
const onRecoveryCodeChange = jest.fn((ev) => { seen = ev.target.value; });
74+
75+
render(<RecoveryCodeForm {...baseProps} recoveryCode="" onRecoveryCodeChange={onRecoveryCodeChange} />);
76+
fireEvent.change(document.getElementById('recovery_code'), { target: { value: 'abcd-1234' } });
77+
78+
expect(onRecoveryCodeChange).toHaveBeenCalledTimes(1);
79+
expect(seen).toBe('abcd-1234');
80+
});
81+
});

0 commit comments

Comments
 (0)