Skip to content
Merged
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
31 changes: 15 additions & 16 deletions src/components/TwoFactorCard.js
Original file line number Diff line number Diff line change
Expand Up @@ -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('');
Expand Down Expand Up @@ -290,21 +291,19 @@ export default function TwoFactorCard({
</div>
) : null}

{!status.enabled ? (
<div className="auth-field">
<label className="auth-label" htmlFor="totp-current-password">
Current password
</label>
<input
id="totp-current-password"
className="auth-input"
type="password"
autoComplete="current-password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
/>
</div>
) : null}
<div className="auth-field">
<label className="auth-label" htmlFor="totp-current-password">
Current password
</label>
<input
id="totp-current-password"
className="auth-input"
type="password"
autoComplete="current-password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
/>
</div>

{setup || status.enabled ? (
<div className="auth-field">
Expand Down
75 changes: 75 additions & 0 deletions src/components/TwoFactorCard.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions src/lib/authService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
26 changes: 26 additions & 0 deletions src/lib/authService.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
// ---------------------------------------------------------------------------
Expand Down
Loading