Skip to content

Commit 05e05f4

Browse files
authored
Merge pull request #18207 from mozilla/FXA-10945
task(recovery-phone): Add support for confirming code on sign in
2 parents 7e52389 + c9ab0fa commit 05e05f4

24 files changed

Lines changed: 517 additions & 99 deletions

libs/accounts/recovery-phone/src/lib/recovery-phone.factories.ts

Lines changed: 0 additions & 28 deletions
This file was deleted.

libs/accounts/recovery-phone/src/lib/recovery-phone.manager.in.spec.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import {
88
testAccountDatabaseSetup,
99
} from '@fxa/shared/db/mysql/account';
1010
import { Test } from '@nestjs/testing';
11-
import { RecoveryPhoneFactory } from './recovery-phone.factories';
1211

1312
describe('RecoveryPhoneManager', () => {
1413
let recoveryPhoneManager: RecoveryPhoneManager;
@@ -57,7 +56,7 @@ describe('RecoveryPhoneManager', () => {
5756
useValue: db,
5857
},
5958
{
60-
provide: 'Redis',
59+
provide: 'RecoveryPhoneRedis',
6160
useValue: mockRedis,
6261
},
6362
],

libs/accounts/recovery-phone/src/lib/recovery-phone.manager.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,9 @@ export class RecoveryPhoneManager {
8282
*
8383
* @param uid
8484
*/
85-
async getConfirmedPhoneNumber(uid: string): Promise<{ phoneNumber: string }> {
85+
async getConfirmedPhoneNumber(
86+
uid: string
87+
): Promise<{ uid: Buffer; phoneNumber: string }> {
8688
const uidBuffer = Buffer.from(uid, 'hex');
8789
const result = await getConfirmedPhoneNumber(this.db, uidBuffer);
8890
if (!result) {
@@ -163,6 +165,19 @@ export class RecoveryPhoneManager {
163165
return JSON.parse(data);
164166
}
165167

168+
/**
169+
* Removes a code from redis. Once a code is validated, it's good to proactively remove it from the database
170+
* so it cannot be used again.
171+
* @param uid The user's unique identifier
172+
* @param code The SMS code associated with this user
173+
* @returns
174+
*/
175+
async removeCode(uid: string, code: string) {
176+
const redisKey = `${this.redisPrefix}:${uid}:${code}`;
177+
const count = await this.redisClient.del(redisKey);
178+
return count > 0;
179+
}
180+
166181
/**
167182
* Check if a user has recovery codes. Recovery codes are required
168183
* to set up a recovery phone.

libs/accounts/recovery-phone/src/lib/recovery-phone.service.spec.ts

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ describe('RecoveryPhoneService', () => {
2929
removePhoneNumber: jest.fn(),
3030
getConfirmedPhoneNumber: jest.fn(),
3131
hasRecoveryCodes: jest.fn(),
32+
removeCode: jest.fn(),
3233
};
3334
const mockOtpManager = { generateCode: jest.fn() };
3435
const mockRecoveryPhoneConfig = {
@@ -157,28 +158,28 @@ describe('RecoveryPhoneService', () => {
157158
});
158159
});
159160

160-
describe('confirm code', () => {
161-
it('can confirm valid sms code', async () => {
162-
mockRecoveryPhoneManager.getUnconfirmed.mockReturnValue({});
163-
164-
const result = await service.confirmCode(uid, code);
165-
166-
expect(result).toBeTruthy();
167-
expect(mockRecoveryPhoneManager.getUnconfirmed).toBeCalledWith(uid, code);
168-
});
169-
161+
describe('confirm setup code', () => {
170162
it('can confirm valid sms code used for setup', async () => {
171163
mockRecoveryPhoneManager.getUnconfirmed.mockReturnValue({
172164
isSetup: true,
173165
});
174166
mockRecoveryPhoneManager.registerPhoneNumber.mockReturnValue(true);
175167

176-
const result = await service.confirmCode(uid, code);
168+
const result = await service.confirmSetupCode(uid, code);
177169

178170
expect(result).toBeTruthy();
179171
expect(mockRecoveryPhoneManager.getUnconfirmed).toBeCalledWith(uid, code);
180172
});
181173

174+
it('will not confirm a valid sms code for signin', async () => {
175+
mockRecoveryPhoneManager.getUnconfirmed.mockReturnValue({});
176+
177+
const result = await service.confirmSetupCode(uid, code);
178+
179+
expect(result).toBeFalsy();
180+
expect(mockRecoveryPhoneManager.getUnconfirmed).toBeCalledWith(uid, code);
181+
});
182+
182183
it('can confirm valid sms code used for setup', async () => {
183184
mockRecoveryPhoneManager.getUnconfirmed.mockResolvedValue({
184185
isSetup: true,
@@ -189,7 +190,7 @@ describe('RecoveryPhoneService', () => {
189190
phoneNumber: '+15005550000',
190191
});
191192

192-
const result = await service.confirmCode(uid, code);
193+
const result = await service.confirmSetupCode(uid, code);
193194

194195
expect(result).toEqual(true);
195196
expect(mockRecoveryPhoneManager.getUnconfirmed).toBeCalledWith(uid, code);
@@ -203,23 +204,52 @@ describe('RecoveryPhoneService', () => {
203204
it('can indicate invalid sms code', async () => {
204205
mockRecoveryPhoneManager.getUnconfirmed.mockReturnValue(null);
205206

206-
const result = await service.confirmCode(uid, code);
207+
const result = await service.confirmSetupCode(uid, code);
207208

208209
expect(result).toEqual(false);
209210
expect(mockRecoveryPhoneManager.getUnconfirmed).toBeCalledWith(uid, code);
210211
});
211212

212213
it('throws library error while confirming sms code', () => {
213214
mockRecoveryPhoneManager.getUnconfirmed.mockRejectedValueOnce(mockError);
214-
expect(service.confirmCode(uid, code)).rejects.toEqual(mockError);
215+
expect(service.confirmSetupCode(uid, code)).rejects.toEqual(mockError);
215216
});
216217

217218
it('throws library error while registering phone number for sms code', () => {
218219
mockRecoveryPhoneManager.getUnconfirmed.mockResolvedValue({
219220
isSetup: true,
220221
});
221222
mockRecoveryPhoneManager.registerPhoneNumber.mockRejectedValue(mockError);
222-
expect(service.confirmCode(uid, code)).rejects.toEqual(mockError);
223+
expect(service.confirmSetupCode(uid, code)).rejects.toEqual(mockError);
224+
});
225+
});
226+
227+
describe('confirm signin code', () => {
228+
it('can confirm valid sms code', async () => {
229+
mockRecoveryPhoneManager.getUnconfirmed.mockReturnValue({});
230+
231+
const result = await service.confirmSigninCode(uid, code);
232+
233+
expect(result).toBeTruthy();
234+
expect(mockRecoveryPhoneManager.getUnconfirmed).toBeCalledWith(uid, code);
235+
});
236+
237+
it('will not confirm valid sms code used for setup', async () => {
238+
mockRecoveryPhoneManager.getUnconfirmed.mockReturnValue({
239+
isSetup: true,
240+
});
241+
242+
const result = await service.confirmSigninCode(uid, code);
243+
244+
expect(result).toBeFalsy();
245+
});
246+
247+
it('will not confirm unknown sms code used', async () => {
248+
mockRecoveryPhoneManager.getUnconfirmed.mockReturnValue(null);
249+
250+
const result = await service.confirmSigninCode(uid, code);
251+
252+
expect(result).toBeFalsy();
223253
});
224254
});
225255

libs/accounts/recovery-phone/src/lib/recovery-phone.service.ts

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -91,27 +91,52 @@ export class RecoveryPhoneService {
9191
* @param code A otp code
9292
* @returns True if successful
9393
*/
94-
public async confirmCode(uid: string, code: string) {
94+
public async confirmSetupCode(uid: string, code: string) {
9595
const data = await this.recoveryPhoneManager.getUnconfirmed(uid, code);
9696

9797
// If there is no data, it means there's no record of this code being sent to the uid provided
9898
if (data == null) {
9999
return false;
100100
}
101101

102+
// The code must be intended for a setup, ie recovery phone create, action.
103+
if (data.isSetup !== true) {
104+
return false;
105+
}
106+
102107
// If this was for a setup operation. Register the phone number to the uid.
108+
const lookupData = await this.smsManager.phoneNumberLookup(
109+
data.phoneNumber
110+
);
111+
await this.recoveryPhoneManager.registerPhoneNumber(
112+
uid,
113+
data.phoneNumber,
114+
lookupData
115+
);
116+
117+
// The code was valid. Remove entry. It cannot be used again.
118+
await this.recoveryPhoneManager.removeCode(uid, code);
119+
120+
// There was a record matching, the uid / code. The confirmation was successful.
121+
return true;
122+
}
123+
124+
public async confirmSigninCode(uid: string, code: string) {
125+
const data = await this.recoveryPhoneManager.getUnconfirmed(uid, code);
126+
127+
// If there is no data, it means there's no record of this code being sent to the uid provided
128+
if (data == null) {
129+
return false;
130+
}
131+
132+
// A code intended for setup, cannot be used for sign in.
103133
if (data.isSetup === true) {
104-
const lookupData = await this.smsManager.phoneNumberLookup(
105-
data.phoneNumber
106-
);
107-
await this.recoveryPhoneManager.registerPhoneNumber(
108-
uid,
109-
data.phoneNumber,
110-
lookupData
111-
);
134+
return false;
112135
}
113136

114-
// There was a record matching, the uid / code. The confirmation was successful.
137+
// The code was valid. Remove entry. It cannot be used again.
138+
await this.recoveryPhoneManager.removeCode(uid, code);
139+
115140
return true;
116141
}
117142

libs/shared/account/account/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@
33
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
44
export * from './lib/account.manager';
55
export * from './lib/account.error';
6+
export { VerificationMethods } from './lib/account.repository';

libs/shared/account/account/src/lib/account.manager.in.spec.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,5 +57,13 @@ describe('accountManager', () => {
5757
accountManager.createAccountStub(email, 1, 'en-US')
5858
).rejects.toBeInstanceOf(AccountAlreadyExistsError);
5959
});
60+
61+
// TODO: Setup tests for verify session
62+
// it.skip('should mark session valid', async () => {
63+
// // TODO: Create an account
64+
// // Create a session with unverified session
65+
// // Call verifySession
66+
// // Validate that session is verified, and unverified session no longer exists.
67+
// });
6068
});
6169
});

libs/shared/account/account/src/lib/account.manager.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@ import { Inject, Injectable } from '@nestjs/common';
77
import type { AccountDatabase } from '@fxa/shared/db/mysql/account';
88
import { AccountDbProvider } from '@fxa/shared/db/mysql/account';
99

10-
import { createAccount, getAccounts } from './account.repository';
10+
import {
11+
createAccount,
12+
getAccounts,
13+
verifyAccountSession,
14+
VerificationMethods,
15+
} from './account.repository';
1116
import { normalizeEmail, randomBytesAsync } from './account.util';
1217
import { uuidTransformer } from '@fxa/shared/db/mysql/core';
1318

@@ -49,4 +54,17 @@ export class AccountManager {
4954
const bufferUids = uids.map((uid) => uuidTransformer.to(uid));
5055
return getAccounts(this.db, bufferUids);
5156
}
57+
58+
async verifySession(
59+
uid: string,
60+
sessionTokenId: string,
61+
verificationMethod: VerificationMethods
62+
) {
63+
return verifyAccountSession(
64+
this.db,
65+
uuidTransformer.to(uid),
66+
uuidTransformer.to(sessionTokenId),
67+
verificationMethod
68+
);
69+
}
5270
}

libs/shared/account/account/src/lib/account.repository.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,83 @@ export function getAccounts(db: AccountDatabase, uids: Buffer[]) {
7272
.where('uid', 'in', uids)
7373
.execute();
7474
}
75+
76+
/** See session_token.js in auth server for master list. */
77+
export enum VerificationMethods {
78+
email = 0,
79+
email2fa = 1,
80+
totp2fa = 2,
81+
recoveryCode = 3,
82+
sms2fa = 4,
83+
}
84+
85+
/**
86+
* Marks account session as verified
87+
* @param db Database instance
88+
* @param uid Users id
89+
* @param sessionTokenId User's session id
90+
* @param verificationMethod, See VerificationMethods
91+
*/
92+
export async function verifyAccountSession(
93+
db: AccountDatabase,
94+
uid: Buffer,
95+
sessionTokenId: Buffer,
96+
verificationMethod: VerificationMethods
97+
): Promise<boolean> {
98+
// It appears that Date.now() results in the number 'format' as UNIX_TIMESTAMP(NOW(3)) * 1000 used
99+
// by the stored procedure.
100+
const now = Date.now();
101+
102+
// Ported from session-token.ts -> verify
103+
104+
return await db.transaction().execute(async (trx) => {
105+
await trx
106+
.updateTable('accounts')
107+
.set({
108+
profileChangedAt: now,
109+
})
110+
.where('uid', '=', uid)
111+
.executeTakeFirstOrThrow();
112+
113+
// Equivalent of 'verifyTokensWithMethod_3' sproc
114+
await trx
115+
.updateTable('sessionTokens')
116+
.set({
117+
verifiedAt: now,
118+
verificationMethod: verificationMethod,
119+
})
120+
.where('tokenId', '=', sessionTokenId)
121+
.executeTakeFirstOrThrow();
122+
123+
// next locate corresponding unverified session tokens
124+
const token = await trx
125+
.selectFrom('sessionTokens')
126+
.innerJoin(
127+
'unverifiedTokens',
128+
'unverifiedTokens.tokenId',
129+
'sessionTokens.tokenId'
130+
)
131+
.select(['unverifiedTokens.tokenVerificationId as tokenVerificationId'])
132+
.where('sessionTokens.tokenId', '=', sessionTokenId)
133+
.executeTakeFirst();
134+
135+
if (token) {
136+
// next mark token as verified. Equivalent to 'verifyToken_3' sproc
137+
await trx
138+
.updateTable('securityEvents')
139+
.set({
140+
verified: 1,
141+
})
142+
.where('uid', '=', uid)
143+
.where('tokenVerificationId', '=', token.tokenVerificationId)
144+
.executeTakeFirstOrThrow();
145+
await trx
146+
.deleteFrom('unverifiedTokens')
147+
.where('uid', '=', uid)
148+
.where('tokenVerificationId', '=', token.tokenVerificationId)
149+
.executeTakeFirstOrThrow();
150+
}
151+
152+
return true;
153+
});
154+
}

0 commit comments

Comments
 (0)