diff --git a/libs/accounts/two-factor/.eslintrc.json b/libs/accounts/two-factor/.eslintrc.json new file mode 100644 index 0000000000..e3f4dc691b --- /dev/null +++ b/libs/accounts/two-factor/.eslintrc.json @@ -0,0 +1,23 @@ +{ + "extends": ["../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.json"], + "parser": "jsonc-eslint-parser", + "rules": {} + } + ] +} diff --git a/libs/accounts/two-factor/README.md b/libs/accounts/two-factor/README.md new file mode 100644 index 0000000000..7e5f77e990 --- /dev/null +++ b/libs/accounts/two-factor/README.md @@ -0,0 +1,11 @@ +# accounts-two-factor + +This library was generated with [Nx](https://nx.dev). + +## Building + +Run `nx build accounts-two-factor` to build the library. + +## Running unit tests + +Run `nx test accounts-two-factor` to execute the unit tests via [Jest](https://jestjs.io). diff --git a/libs/accounts/two-factor/jest.config.ts b/libs/accounts/two-factor/jest.config.ts new file mode 100644 index 0000000000..e9e04e7874 --- /dev/null +++ b/libs/accounts/two-factor/jest.config.ts @@ -0,0 +1,11 @@ +/* eslint-disable */ +export default { + displayName: 'accounts-two-factor', + preset: '../../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../../coverage/libs/accounts/two-factor', +}; diff --git a/libs/accounts/two-factor/package.json b/libs/accounts/two-factor/package.json new file mode 100644 index 0000000000..7e7d953c14 --- /dev/null +++ b/libs/accounts/two-factor/package.json @@ -0,0 +1,4 @@ +{ + "name": "@fxa/accounts/two-factor", + "version": "0.0.1" +} \ No newline at end of file diff --git a/libs/accounts/two-factor/project.json b/libs/accounts/two-factor/project.json new file mode 100644 index 0000000000..788b816ba8 --- /dev/null +++ b/libs/accounts/two-factor/project.json @@ -0,0 +1,46 @@ +{ + "name": "accounts-two-factor", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/accounts/two-factor/src", + "projectType": "library", + "tags": [], + "targets": { + "build": { + "executor": "@nx/esbuild:esbuild", + "outputs": ["{options.outputPath}"], + "defaultConfiguration": "production", + "options": { + "outputPath": "dist/libs/accounts/two-factor", + "tsConfig": "libs/accounts/two-factor/tsconfig.lib.json", + "packageJson": "libs/accounts/two-factor/package.json", + "main": "libs/accounts/two-factor/src/index.ts", + "assets": ["libs/accounts/two-factor/*.md"], + "platform": "node" + }, + "configurations": { + "development": { + "minify": false + }, + "production": { + "minify": true + } + } + }, + "test-unit": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/accounts/two-factor/jest.config.ts", + "testPathPattern": ["^(?!.*\\.in\\.spec\\.ts$).*$"] + } + }, + "test-integration": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/accounts/two-factor/jest.config.ts", + "testPathPattern": ["\\.in\\.spec\\.ts$"] + } + } + } +} diff --git a/libs/accounts/two-factor/src/index.ts b/libs/accounts/two-factor/src/index.ts new file mode 100644 index 0000000000..e8f20e04f8 --- /dev/null +++ b/libs/accounts/two-factor/src/index.ts @@ -0,0 +1 @@ +export * from './lib/backup-code.manager'; diff --git a/libs/accounts/two-factor/src/lib/backup-code.factories.ts b/libs/accounts/two-factor/src/lib/backup-code.factories.ts new file mode 100644 index 0000000000..6beb756ffa --- /dev/null +++ b/libs/accounts/two-factor/src/lib/backup-code.factories.ts @@ -0,0 +1,36 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { faker } from '@faker-js/faker'; +import { RecoveryCode } from './backup-code.types'; + +export const RecoveryCodeFactory = ( + override?: Partial +): RecoveryCode => ({ + uid: Buffer.from( + faker.string.hexadecimal({ + length: 32, + prefix: '', + casing: 'lower', + }), + 'hex' + ), + codeHash: Buffer.from( + faker.string.hexadecimal({ + length: 32, + prefix: '', + casing: 'lower', + }), + 'hex' + ), + salt: Buffer.from( + faker.string.hexadecimal({ + length: 32, + prefix: '', + casing: 'lower', + }), + 'hex' + ), + ...override, +}); diff --git a/libs/accounts/two-factor/src/lib/backup-code.manager.in.spec.ts b/libs/accounts/two-factor/src/lib/backup-code.manager.in.spec.ts new file mode 100644 index 0000000000..2e74b99313 --- /dev/null +++ b/libs/accounts/two-factor/src/lib/backup-code.manager.in.spec.ts @@ -0,0 +1,95 @@ +import { BackupCodeManager } from './backup-code.manager'; +import { + AccountDatabase, + AccountDbProvider, + testAccountDatabaseSetup, +} from '@fxa/shared/db/mysql/account'; +import { RecoveryCodeFactory } from './backup-code.factories'; +import { faker } from '@faker-js/faker'; +import { Test } from '@nestjs/testing'; + +function getMockUid() { + return faker.string.hexadecimal({ + length: 32, + prefix: '', + casing: 'lower', + }); +} + +describe('BackupCodeManager', () => { + let backupCodeManager: BackupCodeManager; + let db: AccountDatabase; + + async function createRecoveryCode(db: AccountDatabase, uid: string) { + return db + .insertInto('recoveryCodes') + .values({ + ...RecoveryCodeFactory({ + uid: Buffer.from(uid, 'hex'), + }), + }) + .execute(); + } + + beforeEach(async () => { + db = await testAccountDatabaseSetup(['accounts', 'recoveryCodes']); + const moduleRef = await Test.createTestingModule({ + providers: [ + BackupCodeManager, + { + provide: AccountDbProvider, + useValue: db, + }, + ], + }).compile(); + + backupCodeManager = moduleRef.get(BackupCodeManager); + }); + + afterAll(async () => { + await db.destroy(); + }); + + it('should return that the user has backup codes and count them', async () => { + const mockUid = getMockUid(); + await createRecoveryCode(db, mockUid); + await createRecoveryCode(db, mockUid); + await createRecoveryCode(db, mockUid); + + const result = await backupCodeManager.getCountForUserId(mockUid); + expect(result.hasBackupCodes).toBe(true); + expect(result.count).toBe(3); + }); + + it('should return that the user has no backup codes', async () => { + const result = await backupCodeManager.getCountForUserId('abcd'); + expect(result.hasBackupCodes).toBe(false); + expect(result.count).toBe(0); + }); + + it('should handle multiple users with different backup code counts', async () => { + const mockUid1 = getMockUid(); + const mockUid2 = getMockUid(); + await createRecoveryCode(db, mockUid1); + await createRecoveryCode(db, mockUid1); + await createRecoveryCode(db, mockUid2); + + const result1 = await backupCodeManager.getCountForUserId(mockUid1); + const result2 = await backupCodeManager.getCountForUserId(mockUid2); + + expect(result1.hasBackupCodes).toBe(true); + expect(result1.count).toBe(2); + expect(result2.hasBackupCodes).toBe(true); + expect(result2.count).toBe(1); + }); + + it('should handle database errors gracefully', async () => { + jest.spyOn(db, 'selectFrom').mockImplementation(() => { + throw new Error('Database error'); + }); + + await expect( + backupCodeManager.getCountForUserId(getMockUid()) + ).rejects.toThrow('Database error'); + }); +}); diff --git a/libs/accounts/two-factor/src/lib/backup-code.manager.ts b/libs/accounts/two-factor/src/lib/backup-code.manager.ts new file mode 100644 index 0000000000..09dc551dbb --- /dev/null +++ b/libs/accounts/two-factor/src/lib/backup-code.manager.ts @@ -0,0 +1,37 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + AccountDatabase, + AccountDbProvider, +} from '@fxa/shared/db/mysql/account'; +import { Inject, Injectable } from '@nestjs/common'; +import { getRecoveryCodes } from './backup-code.repository'; + +@Injectable() +export class BackupCodeManager { + constructor( + @Inject(AccountDbProvider) private readonly db: AccountDatabase + ) {} + + /** + * Gets the count of recover codes for a given uid. + * + * @param uid - The uid in hexadecimal string format. + * @returns An object containing whether the user has backup codes and the count of backup codes. + */ + async getCountForUserId( + uid: string + ): Promise<{ hasBackupCodes: boolean; count: number }> { + const recoveryCodes = await getRecoveryCodes( + this.db, + Buffer.from(uid, 'hex') + ); + + return { + hasBackupCodes: recoveryCodes.length > 0, + count: recoveryCodes.length, + }; + } +} diff --git a/libs/accounts/two-factor/src/lib/backup-code.repository.ts b/libs/accounts/two-factor/src/lib/backup-code.repository.ts new file mode 100644 index 0000000000..4824b286a3 --- /dev/null +++ b/libs/accounts/two-factor/src/lib/backup-code.repository.ts @@ -0,0 +1,12 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import { AccountDatabase } from '@fxa/shared/db/mysql/account'; + +export async function getRecoveryCodes(db: AccountDatabase, uid: Buffer) { + return await db + .selectFrom('recoveryCodes') + .where('uid', '=', uid) + .selectAll() + .execute(); +} diff --git a/libs/accounts/two-factor/src/lib/backup-code.types.ts b/libs/accounts/two-factor/src/lib/backup-code.types.ts new file mode 100644 index 0000000000..5d3ca27e83 --- /dev/null +++ b/libs/accounts/two-factor/src/lib/backup-code.types.ts @@ -0,0 +1,9 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +export interface RecoveryCode { + uid: Buffer; + codeHash: Buffer; + salt: Buffer; +} diff --git a/libs/accounts/two-factor/tsconfig.json b/libs/accounts/two-factor/tsconfig.json new file mode 100644 index 0000000000..25f7201d87 --- /dev/null +++ b/libs/accounts/two-factor/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs" + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/accounts/two-factor/tsconfig.lib.json b/libs/accounts/two-factor/tsconfig.lib.json new file mode 100644 index 0000000000..4befa7f099 --- /dev/null +++ b/libs/accounts/two-factor/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/libs/accounts/two-factor/tsconfig.spec.json b/libs/accounts/two-factor/tsconfig.spec.json new file mode 100644 index 0000000000..69a251f328 --- /dev/null +++ b/libs/accounts/two-factor/tsconfig.spec.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/libs/shared/db/mysql/account/src/lib/tests.ts b/libs/shared/db/mysql/account/src/lib/tests.ts index 64708d7c16..5e8ed8f371 100644 --- a/libs/shared/db/mysql/account/src/lib/tests.ts +++ b/libs/shared/db/mysql/account/src/lib/tests.ts @@ -15,6 +15,7 @@ export type ACCOUNT_TABLES = | 'accountCustomers' | 'paypalCustomers' | 'carts' + | 'recoveryCodes' | 'emails'; export async function testAccountDatabaseSetup( diff --git a/libs/shared/db/mysql/account/src/test/recoveryCodes.sql b/libs/shared/db/mysql/account/src/test/recoveryCodes.sql new file mode 100644 index 0000000000..d82c665335 --- /dev/null +++ b/libs/shared/db/mysql/account/src/test/recoveryCodes.sql @@ -0,0 +1,7 @@ +CREATE TABLE `recoveryCodes` ( + `id` bigint AUTO_INCREMENT PRIMARY KEY, + `uid` binary(16) DEFAULT NULL, + `codeHash` binary(32) DEFAULT NULL, + `salt` binary(32) DEFAULT NULL, + KEY `uid` (`uid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; diff --git a/tsconfig.base.json b/tsconfig.base.json index ef08efa754..09f97613cf 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -27,6 +27,9 @@ "@fxa/accounts/recovery-phone": [ "libs/accounts/recovery-phone/src/index.ts" ], + "@fxa/accounts/two-factor": [ + "libs/accounts/two-factor/src/index.ts" + ], "@fxa/payments/capability": ["libs/payments/capability/src/index.ts"], "@fxa/payments/cart": ["libs/payments/cart/src/index.ts"], "@fxa/payments/currency": ["libs/payments/currency/src/index.ts"], @@ -93,7 +96,9 @@ "@fxa/vendored/jwtool": ["libs/vendored/jwtool/src/index.ts"], "@fxa/vendored/typesafe-node-firestore": [ "libs/vendored/typesafe-node-firestore/src/index.ts" - ] + ], + "accounts/recovery-phone": ["libs/accounts/recovery-phone/src/index.ts"], + "accounts/two-factor": ["libs/accounts/two-factor/src/index.ts"] }, "typeRoots": [ "./types",