diff --git a/src/components/TwoFactorCard.js b/src/components/TwoFactorCard.js index 24540d2..366abcf 100644 --- a/src/components/TwoFactorCard.js +++ b/src/components/TwoFactorCard.js @@ -179,7 +179,8 @@ export default function TwoFactorCard({ setErrCode(null); setSuccess(null); try { - await authService.disableTotp(code); + const oldAuthHash = await deriveStepUpAuthHash(); + await authService.disableTotp({ code, oldAuthHash }); setSetup(null); setCode(''); setCurrentPassword(''); @@ -290,21 +291,19 @@ export default function TwoFactorCard({ ) : null} - {!status.enabled ? ( -
- - setCurrentPassword(e.target.value)} - /> -
- ) : null} +
+ + setCurrentPassword(e.target.value)} + /> +
{setup || status.enabled ? (
diff --git a/src/components/TwoFactorCard.test.js b/src/components/TwoFactorCard.test.js index cb27863..474359f 100644 --- a/src/components/TwoFactorCard.test.js +++ b/src/components/TwoFactorCard.test.js @@ -156,6 +156,81 @@ test('does not finalize TOTP enable UI without recovery codes', async () => { ).not.toBeInTheDocument(); }); +test('requires current password step-up when disabling TOTP', async () => { + const authService = service({ + getTotpStatus: jest.fn().mockResolvedValue({ + enabled: true, + pending: false, + recoveryCodesRemaining: 9, + }), + disableTotp: jest.fn().mockResolvedValue({ status: 'disabled' }), + }); + renderCard(authService); + + await waitFor(() => expect(authService.me).toHaveBeenCalled()); + await waitFor(() => expect(authService.getTotpStatus).toHaveBeenCalled()); + expect(await screen.findByText('Enabled')).toBeInTheDocument(); + await userEvent.type(screen.getByLabelText(/current password/i), 'Correct1!'); + await userEvent.type(screen.getByLabelText(/authenticator code/i), '123456'); + await userEvent.click( + screen.getByRole('button', { name: /disable two-factor authentication/i }) + ); + + await waitFor(() => + expect(authService.disableTotp).toHaveBeenCalledWith({ + code: '123456', + oldAuthHash: 'a'.repeat(64), + }) + ); + expect(authService.deriveStepUpAuthHash).toHaveBeenCalledWith( + 'Correct1!', + 'user@example.com' + ); +}); + +test('keeps current password editable during active TOTP setup', async () => { + const authService = renderCard(); + + await waitFor(() => expect(authService.me).toHaveBeenCalled()); + await waitFor(() => expect(authService.getTotpStatus).toHaveBeenCalled()); + await userEvent.type(screen.getByLabelText(/current password/i), 'OldPass1!'); + await userEvent.click( + screen.getByRole('button', { name: /set up two-factor authentication/i }) + ); + await screen.findByLabelText(/manual setup secret fallback/i); + + const password = screen.getByLabelText(/current password/i); + expect(password).toBeInTheDocument(); + await userEvent.clear(password); + await userEvent.type(password, 'NewPass1!'); + expect(password).toHaveValue('NewPass1!'); +}); + +test('keeps current password editable while recovery codes are displayed', async () => { + const authService = service({ + enableTotp: jest.fn().mockResolvedValue({ + recoveryCodes: ['one', 'two'], + }), + }); + renderCard(authService); + + await waitFor(() => expect(authService.me).toHaveBeenCalled()); + await waitFor(() => expect(authService.getTotpStatus).toHaveBeenCalled()); + await userEvent.type(screen.getByLabelText(/current password/i), 'Correct1!'); + await userEvent.click( + screen.getByRole('button', { name: /set up two-factor authentication/i }) + ); + await screen.findByLabelText(/manual setup secret fallback/i); + await userEvent.type(screen.getByLabelText(/authenticator code/i), '123456'); + await userEvent.click(screen.getByRole('button', { name: /verify and enable/i })); + + expect(await screen.findByText(/save these recovery codes/i)).toBeInTheDocument(); + const password = screen.getByLabelText(/current password/i); + expect(password).toBeInTheDocument(); + await userEvent.type(password, 'DisablePass1!'); + expect(password).toHaveValue('DisablePass1!'); +}); + test('clears stale TOTP errors after a successful status refresh', async () => { const authService = service({ getTotpStatus: jest diff --git a/src/lib/authService.js b/src/lib/authService.js index 80a4942..1b19b42 100644 --- a/src/lib/authService.js +++ b/src/lib/authService.js @@ -206,8 +206,11 @@ export function createAuthService(client = defaultClient) { return res.data; } - async function disableTotp(code) { - const res = await client.post('/auth/totp/disable', { code }); + async function disableTotp({ code, oldAuthHash }) { + if (typeof oldAuthHash !== 'string' || oldAuthHash.length === 0) { + throw new Error('disableTotp: oldAuthHash required'); + } + const res = await client.post('/auth/totp/disable', { code, oldAuthHash }); return res.data; } diff --git a/src/lib/authService.test.js b/src/lib/authService.test.js index cfbdebb..6eb9185 100644 --- a/src/lib/authService.test.js +++ b/src/lib/authService.test.js @@ -375,6 +375,32 @@ describe('authService.updatePrefs', () => { }); }); +describe('authService.disableTotp', () => { + const AUTH = + 'a4f8b3c1d9e7f2a5b1c6d8e4f7a9b2c5d1e8f4a7b3c9d5e1f6a2b8c4d7e3f5a9'; + + test('posts authenticator code and current-password proof', async () => { + const { service, adapter } = makeService(); + let captured; + adapter.onPost('/auth/totp/disable').reply((config) => { + captured = JSON.parse(config.data); + return [200, { status: 'disabled' }]; + }); + + await expect( + service.disableTotp({ code: '123456', oldAuthHash: AUTH }) + ).resolves.toEqual({ status: 'disabled' }); + expect(captured).toEqual({ code: '123456', oldAuthHash: AUTH }); + }); + + test('rejects client-side when oldAuthHash is missing', async () => { + const { service } = makeService(); + await expect(service.disableTotp({ code: '123456' })).rejects.toThrow( + /oldAuthHash required/ + ); + }); +}); + // --------------------------------------------------------------------------- // PR 7 — deleteAccount (GDPR right to erasure) // ---------------------------------------------------------------------------