Skip to content

Commit b7a7b17

Browse files
committed
fix(mfa): consume the factor in the same transaction as the password write (NAN-6586)
A recovery code was spent before the password update ran, so a failed write left the code permanently burned on a reset that never happened. The TOTP counter advanced the same way. verifyTotp and consumeRecoveryCode now take an optional parent transaction, and both handlers verify the factor inside the transaction that writes the password. A rollback un-burns the code, and on the reset path the token stays spendable too.
1 parent d9119d3 commit b7a7b17

6 files changed

Lines changed: 108 additions & 33 deletions

File tree

packages/server/lib/controllers/v1/account/mfa/stepUp.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { mfaService } from '@nangohq/shared';
55
import { isMFAEnabled } from './login.js';
66

77
import type { DBUser, MFACredential } from '@nangohq/types';
8+
import type { Knex } from 'knex';
89

910
export const mfaCredentialSchema = z.discriminatedUnion('type', [
1011
z
@@ -26,8 +27,11 @@ export type StepUpOutcome = 'not_required' | 'verified' | 'required' | 'invalid'
2627
/**
2728
* Second factor for a sensitive action the user is already part way through, rather than for a login.
2829
* Returns 'not_required' when the user has nothing enrolled, so callers stay usable for everyone else.
30+
*
31+
* Call this inside the transaction that performs the action and pass `trx`. A one-time credential is
32+
* spent here, so it has to roll back with the action rather than outlive a failed one.
2933
*/
30-
export async function verifyStepUpMfa(user: DBUser, credential: MFACredential | undefined): Promise<StepUpOutcome> {
34+
export async function verifyStepUpMfa(user: DBUser, credential: MFACredential | undefined, trx: Knex): Promise<StepUpOutcome> {
3135
if (!(await isMFAEnabled(user)) || !(await mfaService.hasActiveFactor(user.id))) {
3236
return 'not_required';
3337
}
@@ -38,8 +42,8 @@ export async function verifyStepUpMfa(user: DBUser, credential: MFACredential |
3842

3943
const verified = (
4044
credential.type === 'recoveryCode'
41-
? await mfaService.consumeRecoveryCode(user.id, credential.recoveryCode)
42-
: await mfaService.verifyTotp(user.id, credential.code)
45+
? await mfaService.consumeRecoveryCode(user.id, credential.recoveryCode, trx)
46+
: await mfaService.verifyTotp(user.id, credential.code, trx)
4347
).unwrap();
4448

4549
return verified ? 'verified' : 'invalid';

packages/server/lib/controllers/v1/account/putResetPassword.integration.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,33 @@ describe(`PUT ${resetPasswordRoute}`, () => {
169169
expect(json).toStrictEqual({ error: { code: 'user_not_found' } });
170170
});
171171

172+
it('should not spend a recovery code when the reset itself fails', async () => {
173+
const { email, password } = await signupVerifiedUser();
174+
const session = await signin(email, password);
175+
const { recoveryCodes } = await enrollMfa(session);
176+
177+
// issue the token before spying, issueResetToken writes through editUserPassword too
178+
const token = await issueResetToken(email);
179+
180+
vi.spyOn(userService, 'editUserPassword').mockRejectedValueOnce(new Error('write failed'));
181+
const failed = await api.fetch(resetPasswordRoute, {
182+
method: 'PUT',
183+
body: { token, password: 'aZ1-newpass!?', mfa: { type: 'recoveryCode', recoveryCode: recoveryCodes[0]! } }
184+
});
185+
expect(failed.res.status).toBe(500);
186+
187+
// the password never changed
188+
await signin(email, password);
189+
190+
// and the rollback left both the recovery code and the reset token spendable
191+
const retry = await api.fetch(resetPasswordRoute, {
192+
method: 'PUT',
193+
body: { token, password: 'aZ1-newpass!?', mfa: { type: 'recoveryCode', recoveryCode: recoveryCodes[0]! } }
194+
});
195+
expect(retry.res.status).toBe(200);
196+
isSuccess(retry.json);
197+
});
198+
172199
it('should skip the second factor when the feature is off for the account', async () => {
173200
const { email, password } = await signupVerifiedUser();
174201
const session = await signin(email, password);

packages/server/lib/controllers/v1/account/putResetPassword.ts

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -55,27 +55,33 @@ export const putResetPassword = asyncWrapper<PutResetPassword>(async (req, res)
5555
return;
5656
}
5757

58-
// Without this the emailed token is the only thing standing between someone with mailbox access
59-
// and the account, which is exactly what the second factor is meant to prevent.
60-
const stepUp = await verifyStepUpMfa(user, mfa);
61-
if (stepUp === 'required') {
58+
const hashedPassword = (await pbkdf2(password, user.salt, PBKDF2_ITERATIONS, 32, 'sha256')).toString('base64');
59+
60+
// Without the second factor the emailed token is the only thing standing between someone with
61+
// mailbox access and the account, which is exactly what MFA is meant to prevent. Verified in the
62+
// same transaction as the write so a failed reset does not spend a one-time code.
63+
const outcome = await db.knex.transaction(async (trx) => {
64+
const stepUp = await verifyStepUpMfa(user, mfa, trx);
65+
if (stepUp !== 'verified' && stepUp !== 'not_required') {
66+
return stepUp;
67+
}
68+
69+
user.hashed_password = hashedPassword;
70+
user.reset_password_token = null;
71+
await userService.editUserPassword(user, trx);
72+
await deleteUserSessions(user.id, { trx });
73+
return 'reset' as const;
74+
});
75+
76+
if (outcome === 'required') {
6277
res.status(400).send({ error: { code: 'mfa_code_required' } });
6378
return;
6479
}
65-
if (stepUp === 'invalid') {
80+
if (outcome === 'invalid') {
6681
res.status(400).send({ error: { code: 'invalid_mfa_code' } });
6782
return;
6883
}
6984

70-
const hashedPassword = (await pbkdf2(password, user.salt, PBKDF2_ITERATIONS, 32, 'sha256')).toString('base64');
71-
72-
user.hashed_password = hashedPassword;
73-
user.reset_password_token = null;
74-
await db.knex.transaction(async (trx) => {
75-
await userService.editUserPassword(user, trx);
76-
await deleteUserSessions(user.id, { trx });
77-
});
78-
7985
res.status(200).json({
8086
success: true
8187
});

packages/server/lib/controllers/v1/user/password/putPassword.integration.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,29 @@ describe(`PUT ${passwordRoute}`, () => {
200200
isSuccess(retry.json);
201201
});
202202

203+
it('should not spend a recovery code when the change itself fails', async () => {
204+
const { email, password } = await signupVerifiedUser();
205+
const session = await signin(email, password);
206+
const { recoveryCodes } = await enrollMfa(session);
207+
208+
vi.spyOn(userService, 'update').mockRejectedValueOnce(new Error('write failed'));
209+
const failed = await api.fetch(passwordRoute, {
210+
method: 'PUT',
211+
session,
212+
body: { oldPassword: password, newPassword: 'aZ1-newpass!?', mfa: { type: 'recoveryCode', recoveryCode: recoveryCodes[0]! } }
213+
});
214+
expect(failed.res.status).toBe(500);
215+
216+
// the password did not change, so the code it consumed has to be spendable again
217+
const retry = await api.fetch(passwordRoute, {
218+
method: 'PUT',
219+
session,
220+
body: { oldPassword: password, newPassword: 'aZ1-newpass!?', mfa: { type: 'recoveryCode', recoveryCode: recoveryCodes[0]! } }
221+
});
222+
expect(retry.res.status).toBe(200);
223+
isSuccess(retry.json);
224+
});
225+
203226
it('should skip the second factor when the feature is off for the account', async () => {
204227
const { email, password } = await signupVerifiedUser();
205228
const session = await signin(email, password);

packages/server/lib/controllers/v1/user/password/putPassword.ts

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -45,25 +45,31 @@ export const putUserPassword = asyncWrapper<PutUserPassword, never>(async (req,
4545
return;
4646
}
4747

48-
// After the password check so a wrong password never consumes a code or a recovery code.
49-
const stepUp = await verifyStepUpMfa(user, body.mfa);
50-
if (stepUp === 'required') {
51-
res.status(400).send({ error: { code: 'mfa_code_required' } });
52-
return;
53-
}
54-
if (stepUp === 'invalid') {
55-
res.status(400).send({ error: { code: 'invalid_mfa_code' } });
56-
return;
57-
}
58-
5948
const salt = crypto.randomBytes(16).toString('base64');
6049
const hashedPassword = (await pbkdf2(body.newPassword, salt, PBKDF2_ITERATIONS, 32, 'sha256')).toString('base64');
6150

62-
await db.knex.transaction(async (trx) => {
51+
// The factor is verified after the old password, so a wrong password never spends a code, and
52+
// inside the same transaction as the write, so a failed write does not spend one either.
53+
const outcome = await db.knex.transaction(async (trx) => {
54+
const stepUp = await verifyStepUpMfa(user, body.mfa, trx);
55+
if (stepUp !== 'verified' && stepUp !== 'not_required') {
56+
return stepUp;
57+
}
58+
6359
await userService.update({ id: user.id, hashed_password: hashedPassword, salt }, trx);
6460
await deleteUserSessions(user.id, { trx });
61+
return 'changed' as const;
6562
});
6663

64+
if (outcome === 'required') {
65+
res.status(400).send({ error: { code: 'mfa_code_required' } });
66+
return;
67+
}
68+
if (outcome === 'invalid') {
69+
res.status(400).send({ error: { code: 'invalid_mfa_code' } });
70+
return;
71+
}
72+
6773
// Re-issue a fresh session so the user who just changed their password stays logged in seamlessly.
6874
// req.logIn regenerates the session id internally (passport's fixation guard), rotating the current
6975
// session. Best effort: if it fails the user can simply re-authenticate with the new password.

packages/shared/lib/services/mfa.service.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -122,9 +122,13 @@ class MFAService {
122122
return new Set(rows.map((row) => row.user_id));
123123
}
124124

125-
public async verifyTotp(userId: number, token: string): Promise<Result<boolean>> {
125+
/**
126+
* Pass `parentTrx` to consume the factor in the caller's transaction, so rolling that back
127+
* un-burns the code rather than leaving it spent on an action that never happened.
128+
*/
129+
public async verifyTotp(userId: number, token: string, parentTrx?: Knex): Promise<Result<boolean>> {
126130
try {
127-
const verified = await db.knex.transaction(async (trx) => {
131+
const verified = await this.inTransaction(parentTrx, async (trx) => {
128132
const factor = await trx<DBMFAFactor>(FACTORS_TABLE).where({ user_id: userId }).whereNotNull('enabled_at').forUpdate().first();
129133
if (!factor) {
130134
return false;
@@ -154,10 +158,11 @@ class MFAService {
154158
}
155159
}
156160

157-
public async consumeRecoveryCode(userId: number, code: string): Promise<Result<boolean>> {
161+
/** See {@link verifyTotp} for `parentTrx`. */
162+
public async consumeRecoveryCode(userId: number, code: string, parentTrx?: Knex): Promise<Result<boolean>> {
158163
try {
159164
const codeHash = this.hashRecoveryCode(code);
160-
const consumed = await db.knex.transaction(async (trx) => {
165+
const consumed = await this.inTransaction(parentTrx, async (trx) => {
161166
const factor = await trx<DBMFAFactor>(FACTORS_TABLE).where({ user_id: userId }).whereNotNull('enabled_at').forUpdate().first();
162167
if (!factor) {
163168
return false;
@@ -253,6 +258,10 @@ class MFAService {
253258
await trx<DBMFARecoveryCode>(RECOVERY_CODES_TABLE).insert(recoveryCodes.map((code) => ({ user_id: userId, code_hash: this.hashRecoveryCode(code) })));
254259
}
255260

261+
private async inTransaction<T>(parentTrx: Knex | undefined, handler: (trx: Knex) => Promise<T>): Promise<T> {
262+
return parentTrx ? await handler(parentTrx) : await db.knex.transaction(handler);
263+
}
264+
256265
private async acquireUserLock(trx: Knex, userId: number): Promise<void> {
257266
await trx.raw('SELECT pg_advisory_xact_lock(?)', [userId]);
258267
}

0 commit comments

Comments
 (0)