packages/fxa-shared/db/models/auth/account.ts (552 lines of code) (raw):

/* 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 crypto, { verify } from 'crypto'; import { fn, raw } from 'objection'; import { BaseAuthModel, Proc } from './base-auth'; import { Email } from './email'; import { Device } from './device'; import { LinkedAccount } from './linked-account'; import { SecurityEvent } from './security-event'; import { intBoolTransformer, uuidTransformer } from '../../transformers'; import { convertError, notFound } from '../../mysql'; export type AccountOptions = { include?: Array<'emails' | 'linkedAccounts' | 'securityEvents'>; }; const selectFields = [ 'uid', 'email', 'emailVerified', 'emailCode', 'kA', 'wrapWrapKb', 'wrapWrapKbVersion2', 'verifierVersion', 'authSalt', 'clientSalt', 'verifierSetAt', 'createdAt', 'locale', 'lockedAt', raw( 'COALESCE(profileChangedAt, verifierSetAt, createdAt) AS profileChangedAt' ), raw('COALESCE(keysChangedAt, verifierSetAt, createdAt) AS keysChangedAt'), 'metricsOptOutAt', 'disabledAt', ]; export class Account extends BaseAuthModel { static tableName = 'accounts'; static idColumn = 'uid'; protected $uuidFields = [ 'authSalt', 'emailCode', 'kA', 'uid', 'verifyHash', 'verifyHashVersion2', 'wrapWrapKb', 'wrapWrapKbVersion2', ]; authSalt!: string; clientSalt?: string; createdAt!: number; devices?: Device[]; disabledAt?: number; email!: string; emailCode!: string; emails?: Email[]; emailVerified!: boolean; kA!: string; keysChangedAt!: number; linkedAccounts?: LinkedAccount[]; locale!: string; lockedAt!: number; metricsOptOutAt?: number; normalizedEmail!: string; primaryEmail?: Email; profileChangedAt!: number; securityEvents?: SecurityEvent[]; uid!: string; verifierSetAt!: number; verifierVersion!: number; verifyHash?: string; verifyHashVersion2?: string; wrapWrapKb!: string; wrapWrapKbVersion2?: string; static relationMappings = { emails: { join: { from: 'accounts.uid', to: 'emails.uid', }, modelClass: Email, relation: BaseAuthModel.HasManyRelation, }, devices: { join: { from: 'accounts.uid', to: 'devices.uid', }, modelClass: Device, relation: BaseAuthModel.HasManyRelation, }, linkedAccounts: { join: { from: 'accounts.uid', to: 'linkedAccounts.uid', }, modelClass: LinkedAccount, relation: BaseAuthModel.HasManyRelation, }, securityEvents: { join: { from: 'accounts.uid', to: 'securityEvents.uid', }, modelClass: SecurityEvent, relation: BaseAuthModel.HasManyRelation, }, }; static async create({ uid, normalizedEmail, email, emailCode, emailVerified, kA, wrapWrapKb, wrapWrapKbVersion2, authSalt, clientSalt, verifierVersion, verifyHash, verifyHashVersion2, verifierSetAt, createdAt, locale, }: Pick< Account, | 'uid' | 'normalizedEmail' | 'email' | 'emailCode' | 'emailVerified' | 'kA' | 'wrapWrapKb' | 'wrapWrapKbVersion2' | 'authSalt' | 'clientSalt' | 'verifierVersion' | 'verifyHash' | 'verifyHashVersion2' | 'verifierSetAt' | 'createdAt' | 'locale' >) { try { await Account.callProcedure( Proc.CreateAccount, uuidTransformer.to(uid), normalizedEmail, email, uuidTransformer.to(emailCode), emailVerified, uuidTransformer.to(kA), uuidTransformer.to(wrapWrapKb), wrapWrapKbVersion2 ? uuidTransformer.to(wrapWrapKbVersion2) : null, uuidTransformer.to(authSalt), clientSalt ? clientSalt : null, verifierVersion, uuidTransformer.to(verifyHash), verifyHashVersion2 ? uuidTransformer.to(verifyHashVersion2) : null, verifierSetAt, createdAt, locale ?? '' ); } catch (e: any) { throw convertError(e); } } static async delete(uid: string) { return Account.callProcedure(Proc.DeleteAccount, uuidTransformer.to(uid)); } static async reset({ uid, verifyHash, verifyHashVersion2, wrapWrapKb, wrapWrapKbVersion2, authSalt, clientSalt, verifierSetAt, verifierVersion, keysHaveChanged, isPasswordUpgrade, }: Pick< Account, | 'uid' | 'authSalt' | 'verifierSetAt' | 'verifierVersion' | 'verifyHash' | 'verifyHashVersion2' | 'wrapWrapKb' | 'wrapWrapKbVersion2' | 'clientSalt' > & { keysHaveChanged?: boolean; isPasswordUpgrade?: boolean; }) { return Account.callProcedure( Proc.ResetAccount, uuidTransformer.to(uid), uuidTransformer.to(verifyHash), verifyHashVersion2 ? uuidTransformer.to(verifyHashVersion2) : null, uuidTransformer.to(authSalt), clientSalt ? clientSalt : null, uuidTransformer.to(wrapWrapKb), wrapWrapKbVersion2 ? uuidTransformer.to(wrapWrapKbVersion2) : null, verifierSetAt || Date.now(), verifierVersion, !!keysHaveChanged, !!isPasswordUpgrade ); } static async updateLocale(uid: string, locale: string) { await Account.query() .update({ locale, }) .where('uid', uuidTransformer.to(uid)); } static async setPrimaryEmail(uid: string, email: string) { try { await Account.callProcedure( Proc.SetPrimaryEmail, uuidTransformer.to(uid), email ); } catch (e: any) { e = convertError(e); if (e.errno === 101) { e.errno = 148; e.statusCode = 400; } throw e; } } static async createEmail({ uid, normalizedEmail, email, emailCode, isVerified, verifiedAt, }: Pick<Account, 'uid' | 'normalizedEmail' | 'email' | 'emailCode'> & { isVerified: boolean; verifiedAt: number; }) { try { await Account.callProcedure( Proc.CreateEmail, normalizedEmail, email, uuidTransformer.to(uid), uuidTransformer.to(emailCode), intBoolTransformer.to(isVerified), verifiedAt ?? null, Date.now() ); } catch (e: any) { throw convertError(e); } } static async verifyEmail(uid: string, emailCode: string) { try { await Account.callProcedure( Proc.VerifyEmail, uuidTransformer.to(uid), uuidTransformer.to(emailCode) ); } catch (e: any) { throw convertError(e); } } static async deleteEmail(uid: string, email: string) { try { await Account.callProcedure( Proc.DeleteEmail, uuidTransformer.to(uid), email ); } catch (e: any) { throw convertError(e); } } static async createUnblockCode(uid: string, code: string) { const id = uuidTransformer.to(uid); try { await Account.callProcedure( Proc.CreateUnblockCode, id, BaseAuthModel.sha256(Buffer.concat([id, Buffer.from(code, 'utf8')])), Date.now() ); } catch (e: any) { throw convertError(e); } } static async consumeUnblockCode(uid: string, code: string) { const id = uuidTransformer.to(uid); try { const { rows } = await Account.callProcedure( Proc.ConsumeUnblockCode, id, BaseAuthModel.sha256(Buffer.concat([id, Buffer.from(code, 'utf8')])) ); if (rows.length < 1 || !(rows[0] as any).createdAt) { throw notFound(); } return rows[0]; } catch (e: any) { throw convertError(e); } } static async createSigninCode(uid: string, code: string, flowId?: string) { try { await Account.callProcedure( Proc.CreateSigninCode, // hash of the utf8 string, not the decoded hex buffer :( crypto.createHash('sha256').update(code).digest(), uuidTransformer.to(uid), Date.now(), flowId ? uuidTransformer.to(flowId) : null ); } catch (e: any) { throw convertError(e); } } static async consumeSigninCode(code: string, maxAge: number = 172800000) { const newerThan = Date.now() - maxAge; try { const { rows } = await Account.callProcedure( Proc.ConsumeSigninCode, // hash of the utf8 string, not the decoded hex buffer :( crypto.createHash('sha256').update(code).digest(), newerThan ); if (rows.length < 1) { throw notFound(); } const row = rows[0] as { flowId: Buffer; email: string }; return { flowId: uuidTransformer.from(row.flowId), email: row.email, }; } catch (e: any) { throw convertError(e); } } static async resetTokens(uid: string) { try { await Account.callProcedure( Proc.ResetAccountTokens, uuidTransformer.to(uid) ); } catch (e: any) { throw convertError(e); } } static async replaceRecoveryCodes( uid: string, hashes: { hash: Buffer; salt: Buffer }[] ) { try { const id = uuidTransformer.to(uid); await Account.transaction(async (txn) => { await Account.callProcedure(Proc.DeleteRecoveryCodes, txn, id); for (const { hash, salt } of hashes) { await Account.callProcedure( Proc.CreateRecoveryCode, txn, id, hash, salt ); } }); } catch (e: any) { throw convertError(e); } } static async consumeRecoveryCode( uid: string, codeChecker: (hash: Buffer, salt: Buffer) => Promise<boolean> ) { try { const id = uuidTransformer.to(uid); const { rows } = await Account.callProcedure(Proc.RecoveryCodes, id); for (const row of rows) { const matches = await codeChecker(row.codeHash, row.salt); if (matches) { const { rows: [result], } = await Account.callProcedure( Proc.ConsumeRecoveryCode, id, row.codeHash ); return result.count as number; } } throw notFound(); } catch (e: any) { throw convertError(e); } } static async checkPassword(uid: string, verifyHash: string) { let [account] = await Account.query() .select('verifyHash', 'verifyHashVersion2') .where('uid', uuidTransformer.to(uid)); const v1 = account.verifyHash === verifyHash; const v2 = account.verifyHashVersion2 === verifyHash; return { match: v1 || v2, v1, v2, }; } static async createPassword( uid: string, authSalt: string, clientSalt: string | undefined, verifyHash: string, verifyHashVersion2: string | undefined, wrapWrapKb: string, wrapWrapKbVersion2: string | undefined, verifierVersion: number ) { const now = Date.now(); await Account.query() .update({ authSalt: uuidTransformer.to(authSalt), clientSalt: clientSalt ? clientSalt : null, verifyHash: uuidTransformer.to(verifyHash), verifyHashVersion2: verifyHashVersion2 ? uuidTransformer.to(verifyHashVersion2) : null, wrapWrapKb: uuidTransformer.to(wrapWrapKb), wrapWrapKbVersion2: wrapWrapKbVersion2 ? uuidTransformer.to(wrapWrapKbVersion2) : null, verifierSetAt: now, keysChangedAt: now, profileChangedAt: now, verifierVersion, } as any) .where('uid', uuidTransformer.to(uid)); return now; } static async findByPrimaryEmail( email: string, options: { linkedAccounts?: boolean } = { linkedAccounts: false } ): Promise<(Account & { linkedAccounts?: LinkedAccount[] }) | null> { let account: Account | null = null; const { rows } = await Account.callProcedure(Proc.AccountRecord, email); if (rows.length) { account = Account.fromDatabaseJson(rows[0]); } if (!account) { // There is a possibility that this email exists on the account table (ex. deleted from emails table) // Lets check before throwing account not found. account = (await Account.query() .select(...selectFields) .where('normalizedEmail', fn.lower(email)) .first()) || null; if (!account) { return null; } } account.emails = await Email.findByUid(account.uid); account.primaryEmail = account.emails?.find((email) => email.isPrimary); if (options.linkedAccounts) { account.linkedAccounts = await LinkedAccount.findByUid(account.uid); } return account; } static async listAllUnverified(options?: AccountOptions) { const allAccounts = await Account.query() .select(...selectFields) .where('verifierSetAt', 0); if (options?.include?.includes('emails')) { for (let account of allAccounts) { account.emails = await Email.findByUid(account.uid); account.primaryEmail = account.emails.find((email) => email.isPrimary); } } return allAccounts; } static async findByUid( uid: string, options?: AccountOptions ): Promise<Account | null> { const account = await Account.query() .select(...selectFields) .where('uid', uuidTransformer.to(uid)) .first(); if (!account) { return null; } // it's actually faster as separate queries if (options?.include?.includes('emails')) { account.emails = await Email.findByUid(uid); account.primaryEmail = account.emails?.find((email) => email.isPrimary); } if (options?.include?.includes('linkedAccounts')) { account.linkedAccounts = await LinkedAccount.findByUid(uid); } if (options?.include?.includes('securityEvents')) { account.securityEvents = await SecurityEvent.findByUid(uid); } return account; } static async getEmailUnverifiedAccounts(options: { startCreatedAtDate: number; endCreatedAtDate: number; limit?: number; fields?: string[]; }) { const accounts = Account.query() .select(...(options.fields || selectFields)) .whereBetween('createdAt', [ options.startCreatedAtDate, options.endCreatedAtDate, ]) .andWhere('emailVerified', 0); if (options.limit) { accounts.limit(options.limit); } return await accounts; } static async setMetricsOpt( uid: string, state: 'in' | 'out', timestamp: number = Date.now() ) { await Account.query() .update({ metricsOptOutAt: state === 'out' ? timestamp : null, profileChangedAt: Date.now(), } as any) .where('uid', uuidTransformer.to(uid)); } static async metricsEnabled(uid: string) { try { const { metricsOptOutAt } = (await Account.query() .select('metricsOptOutAt') .where('uid', uuidTransformer.to(uid)) .first()) || {}; return !metricsOptOutAt; } catch (error) { // err on caution / don't throw return false; } } static async securityEvents(uid: string) { return SecurityEvent.findByUid(uid); } }