,
+ cookie?: string,
+ ) =>
+ fetch(new URL(path, env.origin), {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ ...(cookie ? { cookie } : {}),
+ },
+ body: JSON.stringify(body),
+ });
+
+ it('allows an unused recovery code to disable 2FA after password revalidation', async () => {
+ const username = `lost2fa_${uuidv4().slice(0, 8)}`;
+ await createTestUser(env.server, { username, password });
+ const user = (await env.server.stores.user.getByUsername(username))!;
+ await env.server.stores.user.update(user.id, {
+ email: `${username}@example.com`,
+ email_confirmed: true,
+ otp_enabled: true,
+ otp_secret: 'TESTSECRETBASE32',
+ otp_recovery_codes: ['RECOVER1', 'RECOVER2', 'RECOVER3']
+ .map(hashRecoveryCode)
+ .join(','),
+ });
+
+ // Sign in with a recovery code: works, and yields a web session.
+ const login = await post('/login', { username, password });
+ expect(login.status).toBe(202);
+ const { otp_jwt_token } = await login.json();
+ const recovered = await post('/login/recovery-code', {
+ token: otp_jwt_token,
+ code: 'RECOVER1',
+ });
+ expect((await recovered.json()).next_step).toBe('complete');
+ const cookie = recovered.headers
+ .getSetCookie()
+ .find((value) => value.startsWith('puter_auth_token='))!
+ .split(';')[0]!;
+
+ const reused = await post(
+ '/user-protected/disable-2fa',
+ { password, code: 'RECOVER1' },
+ cookie,
+ );
+ expect(reused.status).toBe(401);
+ expect(
+ (await env.server.stores.user.getById(user.id, { force: true }))!
+ .otp_enabled,
+ ).toBeTruthy();
+
+ const disable = await post(
+ '/user-protected/disable-2fa',
+ { password, code: 'RECOVER2' },
+ cookie,
+ );
+ expect(disable.status).toBe(200);
+ expect(await disable.json()).toMatchObject({ success: true });
+
+ const after = await env.server.stores.user.getById(user.id, {
+ force: true,
+ });
+ expect(after!.otp_enabled).toBeFalsy();
+ expect(after!.otp_secret).toBeNull();
+ expect(after!.otp_recovery_codes).toBeNull();
+ });
+});
diff --git a/src/gui/src/UI/Settings/UIWindowDisable2FA.js b/src/gui/src/UI/Settings/UIWindowDisable2FA.js
index 6d33a4a7a..d945c79e5 100644
--- a/src/gui/src/UI/Settings/UIWindowDisable2FA.js
+++ b/src/gui/src/UI/Settings/UIWindowDisable2FA.js
@@ -53,8 +53,8 @@ async function UIWindowDisable2FA (options) {
h += '';
h += '';
h += '';
- h += ``;
- h += ``;
+ h += ``;
+ h += ``;
h += '
';
h += ``;
h += '';
@@ -204,7 +204,8 @@ async function UIWindowDisable2FA (options) {
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
- code: $(el_window).find('.disable-2fa-code').val() || undefined,
+ // Normalized like the login window's recovery input.
+ code: $(el_window).find('.disable-2fa-code').val()?.trim().toUpperCase() || undefined,
password: password !== undefined && password !== '' ? password : undefined,
}),
});
diff --git a/src/gui/src/UI/Settings/UIWindowDisable2FA.test.js b/src/gui/src/UI/Settings/UIWindowDisable2FA.test.js
index 3576a2587..48db5193b 100644
--- a/src/gui/src/UI/Settings/UIWindowDisable2FA.test.js
+++ b/src/gui/src/UI/Settings/UIWindowDisable2FA.test.js
@@ -59,6 +59,14 @@ describe('disabling 2FA', () => {
await expect(Promise.resolve(state.result.promise)).resolves.toBe(true);
});
+ it('trims and uppercases a pasted recovery code', async () => {
+ await open({ otp: true });
+ jQuery(state.el).find('.disable-2fa-code').val(' abcd2345\n');
+ fetch.mockResolvedValue(reply(200));
+ await submit();
+ expect(bodyOf(0)).toEqual({ password: 'password', code: 'ABCD2345' });
+ });
+
it.each([400, 401])('leaves 2FA enabled and permits retry after a code rejection (%s)', async status => {
await open({ otp: true });
jQuery(state.el).find('.disable-2fa-code').val('000123');
diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js
index 1125488f6..be91aedf8 100644
--- a/src/gui/src/i18n/translations/en.js
+++ b/src/gui/src/i18n/translations/en.js
@@ -177,7 +177,8 @@ const en = {
directory_depth_limit_exceeded: 'This folder is nested too deeply. Move it somewhere closer to the top before adding more folders inside it.',
disable_2fa: 'Disable 2FA',
disable_2fa_confirm: 'Are you sure you want to disable 2FA?',
- disable_2fa_instructions: 'Confirm your identity and enter the code from your authenticator app to disable 2FA.',
+ disable_2fa_instructions: 'Confirm your identity and enter an authenticator code or an unused recovery code to disable 2FA.',
+ disable_2fa_code_instruction: 'Enter a 6-digit authenticator code or an unused recovery code.',
disassociate_dir: 'Disassociate Directory',
display_name: 'Display Name',
directory: 'Directory',