packages/fxa-auth-server/lib/db.ts (1,193 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 from 'crypto'; import { filterExpiredTokens, mergeCachedSessionTokens, mergeDeviceAndSessionToken, mergeDevicesAndSessionTokens, } from 'fxa-shared/connected-services'; import { setupAuthDatabase } from 'fxa-shared/db'; import { Account, BaseToken, Device, Email, EmailBounce, LinkedAccount, AccountResetToken as RawAccountResetToken, KeyFetchToken as RawKeyFetchToken, PasswordChangeToken as RawPasswordChangeToken, PasswordForgotToken as RawPasswordForgotToken, SessionToken as RawSessionToken, RecoveryKey, SecurityEvent, TotpToken, } from 'fxa-shared/db/models/auth'; import { normalizeEmail } from 'fxa-shared/email/helpers'; import { StatsD } from 'hot-shots'; import { Container } from 'typedi'; import random, { base32 } from './crypto/random'; import error from './error'; import { verificationMethodToString, VerificationMethod, } from 'fxa-shared/db/models/auth/session-token'; function resolveMetrics(): StatsD | undefined { return Container.has(StatsD) ? Container.get(StatsD) : undefined; } // Note that these errno's were defined in the fxa-auth-db-mysql repo // and don't necessarily match the errnos in this repo... function isRecordAlreadyExistsError(err: any) { return err.statusCode === 409 && err.errno === 101; } function isNotFoundError(err: any) { return err.statusCode === 404 && err.errno === 116; } function isEmailAlreadyExistsError(err: any) { return err.statusCode === 409 && err.errno === 101; } function isEmailDeletePrimaryError(err: any) { return err.statusCode === 400 && err.errno === 136; } export const createDB = ( config: any, log: any, Token: any, UnblockCode: any = null ) => { const scrypt = require('./crypto/scrypt')(log, config); const features = require('./features')(config); const { SessionToken, KeyFetchToken, AccountResetToken, PasswordForgotToken, PasswordChangeToken, } = Token; const MAX_AGE_SESSION_TOKEN_WITHOUT_DEVICE = config.tokenLifetimes.sessionTokenWithoutDevice; const { enabled: TOKEN_PRUNING_ENABLED, maxAge: TOKEN_PRUNING_MAX_AGE } = config.tokenPruning; class DB { redis: any; metrics?: StatsD; constructor(options: { redis?: any; metrics?: StatsD }) { this.redis = options.redis || require('./redis')( { ...config.redis, ...config.redis.sessionTokens }, log ); this.metrics = options.metrics || resolveMetrics(); } static async connect(config: any, redis: any) { // Establish database connection and bind instance to Model using Knex const metrics = resolveMetrics(); const knex = setupAuthDatabase( config.database?.mysql?.auth, log, metrics ); if (['debug', 'verbose', 'trace'].includes(config.log?.level)) { knex.on('query', (data) => { console.dir(data); }); } return new DB({ redis, metrics }); } async close() { if (this.redis) { await this.redis.close(); } } async ping() { await Account.query().limit(1); return true; } // CREATE async createAccount(data: any) { const { uid, email } = data; log.trace('DB.createAccount', { uid, email }); data.verifierSetAt = data.verifierSetAt ?? Date.now(); // allow 0 to indicate no-password-set data.createdAt = Date.now(); data.normalizedEmail = normalizeEmail(data.email); data.primaryEmail = { email, emailCode: data.emailCode, normalizeEmail: data.normalizedEmail, isVerified: data.emailVerified, }; try { await Account.create(data); this.metrics?.increment('db.account.created', { result: 'success' }); return data; } catch (err) { if (isRecordAlreadyExistsError(err)) { this.metrics?.increment('db.account.created', { result: 'accountExists', }); throw error.accountExists(data.email); } this.metrics?.increment('db.account.created', { result: 'error' }); throw err; } } async createSessionToken(authToken: any) { const { uid } = authToken; log.trace('DB.createSessionToken', { uid }); const sessionToken = await SessionToken.create(authToken); const { id } = sessionToken; // Ensure there are no clashes with zombie tokens left behind in Redis try { await this.deleteSessionTokenFromRedis(uid, id); } catch (unusedErr) { // Ignore errors deleting the token. } await RawSessionToken.create(sessionToken); this.metrics?.increment('db.sessionToken.created'); return sessionToken; } async createKeyFetchToken(authToken: any) { log.trace('DB.createKeyFetchToken', { uid: authToken && authToken.uid }); const keyFetchToken = await KeyFetchToken.create(authToken); await RawKeyFetchToken.create(keyFetchToken); this.metrics?.increment('db.keyFetchToken.created'); return keyFetchToken; } async createPasswordForgotToken(emailRecord: any) { log.trace('DB.createPasswordForgotToken', { uid: emailRecord && emailRecord.uid, }); const passwordForgotToken = await PasswordForgotToken.create(emailRecord); await RawPasswordForgotToken.create(passwordForgotToken); this.metrics?.increment('db.passwordForgotToken.created'); return passwordForgotToken; } async createPasswordChangeToken(data: any) { log.trace('DB.createPasswordChangeToken', { uid: data.uid }); const passwordChangeToken = await PasswordChangeToken.create(data); await RawPasswordChangeToken.create(passwordChangeToken); this.metrics?.increment('db.passwordChangeToken.created'); return passwordChangeToken; } // READ async checkPassword(uid: string, verifyHash: string) { log.trace('DB.checkPassword', { uid, verifyHash }); const result = await Account.checkPassword(uid, verifyHash); if (result.v1) { this.metrics?.increment('check.password.v1.success'); } if (result.v2) { this.metrics?.increment('check.password.v2.success'); } return result; } async accountExists(email: string) { log.trace('DB.accountExists', { email: email }); // TODO this could be optimized with a new query const account = await Account.findByPrimaryEmail(email); this.metrics?.increment('db.account.exists', { exists: (!!account).toString(), }); return !!account; } async sessions(uid: string) { log.trace('DB.sessions', { uid }); const getMysqlSessionTokens = async () => { const { sessionTokens, expiredSessionTokens } = filterExpiredTokens( await RawSessionToken.findByUid(uid), MAX_AGE_SESSION_TOKEN_WITHOUT_DEVICE ); if (expiredSessionTokens.length === 0) { return sessionTokens; } // Prune session tokens try { await this.pruneSessionTokens(uid, expiredSessionTokens); } catch (unusedErr) { // Ignore errors } return sessionTokens; }; const promises = [getMysqlSessionTokens()]; if (this.redis) { promises.push(this.redis.getSessionTokens(uid)); } const [mysqlSessionTokens, redisSessionTokens = {}] = await Promise.all(promises); // for each db session token, if there is a matching redis token // overwrite the properties of the db token with the redis token values const lastAccessTimeEnabled = features.isLastAccessTimeEnabledForUser(uid); const sessions = mergeCachedSessionTokens( mysqlSessionTokens, redisSessionTokens, lastAccessTimeEnabled ); log.debug('db.sessions.count', { lastAccessTimeEnabled, mysql: mysqlSessionTokens.length, redis: Object.keys(redisSessionTokens).length, }); this.metrics?.increment('db.sessions'); return sessions; } async keyFetchToken(id: string) { log.trace('DB.keyFetchToken', { id }); const data = await RawKeyFetchToken.findByTokenId(id); if (!data) { this.metrics?.increment('db.keyFetchToken.retrieve', { result: 'notFound', }); throw error.invalidToken('The authentication token could not be found'); } this.metrics?.increment('db.keyFetchToken.retrieve', { result: 'success', }); return KeyFetchToken.fromId(id, data); } async keyFetchTokenWithVerificationStatus(id: string) { log.trace('DB.keyFetchTokenWithVerificationStatus', { id }); const data = await RawKeyFetchToken.findByTokenId(id, true); if (!data) { this.metrics?.increment( 'db.keyFetchTokenWithVerificationStatus.retrieve', { result: 'notFound' } ); throw error.invalidToken('The authentication token could not be found'); } this.metrics?.increment( 'db.keyFetchTokenWithVerificationStatus.retrieve', { result: 'success' } ); return KeyFetchToken.fromId(id, data); } async accountResetToken(id: string) { log.trace('DB.accountResetToken', { id }); const data = await RawAccountResetToken.findByTokenId(id); if (!data) { this.metrics?.increment('db.accountResetToken.retrieve', { result: 'notFound', }); throw error.invalidToken('The authentication token could not be found'); } this.metrics?.increment('db.accountResetToken.retrieve', { result: 'success', }); return AccountResetToken.fromHex(data.tokenData, data); } async passwordForgotToken(id: string) { log.trace('DB.passwordForgotToken', { id }); const data = await RawPasswordForgotToken.findByTokenId(id); if (!data) { this.metrics?.increment('db.passwordForgotToken.retrieve', { result: 'notFound', }); throw error.invalidToken('The authentication token could not be found'); } this.metrics?.increment('db.passwordForgotToken.retrieve', { result: 'success', }); return PasswordForgotToken.fromHex(data.tokenData, data); } async passwordChangeToken(id: string) { log.trace('DB.passwordChangeToken', { id }); const data = await RawPasswordChangeToken.findByTokenId(id); if (!data) { this.metrics?.increment('db.passwordChangeToken.retrieve', { result: 'notFound', }); throw error.invalidToken('The authentication token could not be found'); } this.metrics?.increment('db.passwordChangeToken.retrieve', { result: 'success', }); return PasswordChangeToken.fromHex(data.tokenData, data); } async accountRecord(email: string, options?: { linkedAccounts?: boolean }) { log.trace('DB.accountRecord', { email }); const account = await Account.findByPrimaryEmail(email, options); if (!account) { this.metrics?.increment('db.accountRecord.retrieve', { result: 'notFound', }); throw error.unknownAccount(email); } this.metrics?.increment('db.accountRecord.retrieve', { result: 'success', }); return account; } // Legacy alias // TODO delete me emailRecord = this.accountRecord; async account( uid: string ): Promise<Account & Required<Pick<Account, 'emails'>>> { log.trace('DB.account', { uid }); const account = (await Account.findByUid(uid, { include: ['emails'], })) as Account & Required<Pick<Account, 'emails'>>; if (!account) { this.metrics?.increment('db.account.retrieve', { result: 'notFound' }); throw error.unknownAccount(); } this.metrics?.increment('db.account.retrieve', { result: 'success' }); return account; } async listAllUnverifiedAccounts() { log.trace('DB.listAllUnverifiedAccounts'); return await Account.listAllUnverified({ include: ['emails'] }); } async getEmailUnverifiedAccounts(options: any) { log.trace('DB.getEmailUnverifiedAccounts'); return await Account.getEmailUnverifiedAccounts(options); } async devices(uid: string) { log.trace('DB.devices', { uid }); if (!uid) { this.metrics?.increment('db.devices.retrieve', { result: 'uidNotFound', }); throw error.unknownAccount(); } const promises = [Device.findByUid(uid)]; if (this.redis) { promises.push(this.redis.getSessionTokens(uid)); } try { const [devices, redisSessionTokens = {}] = await Promise.all(promises); const lastAccessTimeEnabled = features.isLastAccessTimeEnabledForUser(uid); this.metrics?.increment('db.devices.retrieve', { result: 'success' }); return mergeDevicesAndSessionTokens( devices, redisSessionTokens, lastAccessTimeEnabled ); } catch (err) { if (isNotFoundError(err)) { this.metrics?.increment('db.devices.retrieve', { result: 'notFound', }); throw error.unknownAccount(); } this.metrics?.increment('db.devices.retrieve', { result: 'error' }); throw err; } } async sessionToken(id: string) { log.trace('DB.sessionToken', { id }); const data = await RawSessionToken.findByTokenId(id); if (!data) { this.metrics?.increment('db.sessionToken.retrieve', { result: 'notFound', }); throw error.invalidToken('The authentication token could not be found'); } this.metrics?.increment('db.sessionToken.retrieve', { result: 'success', }); return SessionToken.fromHex(data.tokenData, data); } async accountEmails(uid: string) { log.trace('DB.accountEmails', { uid }); return Email.findByUid(uid); } async device(uid: string, deviceId: string) { log.trace('DB.device', { uid: uid, id: deviceId }); const promises = [Device.findByPrimaryKey(uid, deviceId)]; if (this.redis) { promises.push(this.redis.getSessionTokens(uid)); } const [device, redisSessionTokens = {}] = await Promise.all(promises); if (!device) { this.metrics?.increment('db.device.retrieve', { result: 'notFound' }); throw error.unknownDevice(); } const lastAccessTimeEnabled = features.isLastAccessTimeEnabledForUser(uid); const token = (redisSessionTokens as any)[device.sessionTokenId]; this.metrics?.increment('db.device.retrieve', { result: 'success' }); return mergeDeviceAndSessionToken(device, token, lastAccessTimeEnabled); } async getSecondaryEmail(email: string) { log.trace('DB.getSecondaryEmail', { email }); const emailRecord = await Email.findByEmail(email); if (!emailRecord) { throw error.unknownSecondaryEmail(); } return emailRecord; } async getLinkedAccounts(uid: string) { log.trace('DB.getLinkedAccounts', { uid }); this.metrics?.increment('db.linkedAccounts.retrieve'); return LinkedAccount.findByUid(uid); } async createLinkedAccount( uid: string, id: string, provider: any ): Promise<LinkedAccount> { log.trace('DB.createLinkedAccount', { uid, id, provider }); this.metrics?.increment('db.linkedAccount.create'); return LinkedAccount.createLinkedAccount(uid, id, provider); } async deleteLinkedAccount(uid: string, provider: any) { log.trace('DB.deleteLinkedAccount', { uid, provider }); this.metrics?.increment('db.linkedAccount.delete'); return LinkedAccount.deleteLinkedAccount(uid, provider); } async getLinkedAccount(id: string, provider: any) { log.trace('DB.getLinkedAccount', { id, provider }); this.metrics?.increment('db.linkedAccount.retrieve'); return LinkedAccount.findByLinkedAccount(id, provider); } async totpToken(uid: string) { log.trace('DB.totpToken', { uid }); const totp = await TotpToken.findByUid(uid); if (!totp) { this.metrics?.increment('db.totpToken.retrieve', { result: 'notFound', }); throw error.totpTokenNotFound(); } this.metrics?.increment('db.totpToken.retrieve', { result: 'success' }); return totp; } async getRecoveryKey(uid: string, recoveryKeyId: string) { log.trace('DB.getRecoveryKey', { uid }); const data = await RecoveryKey.findByUid(uid); if (!data) { this.metrics?.increment('db.recoveryKey.retrieve', { result: 'notFound', }); throw error.recoveryKeyNotFound(); } const idHash = crypto .createHash('sha256') .update(Buffer.from(recoveryKeyId, 'hex') as any) .digest(); if ( !crypto.timingSafeEqual( idHash as any, Buffer.from(data.recoveryKeyIdHash, 'hex') as any ) ) { this.metrics?.increment('db.recoveryKey.retrieve', { result: 'invalid', }); throw error.recoveryKeyInvalid(); } this.metrics?.increment('db.recoveryKey.retrieve', { result: 'success' }); return data; } async recoveryKeyExists(uid: string) { log.trace('DB.recoveryKeyExists', { uid }); this.metrics?.increment('db.recoveryKey.exists'); return { exists: await RecoveryKey.exists(uid), }; } async emailBounces(email: string) { log.trace('DB.emailBounces', { email }); return EmailBounce.findByEmail(email); } async deviceFromTokenVerificationId( uid: string, tokenVerificationId: string ) { log.trace('DB.deviceFromTokenVerificationId', { uid, tokenVerificationId, }); const device = await Device.findByUidAndTokenVerificationId( uid, tokenVerificationId ); if (!device) { throw error.unknownDevice(); } return device; } // UPDATE async setPrimaryEmail(uid: string, email: string) { log.trace('DB.setPrimaryEmail', { email }); try { const account = await Account.setPrimaryEmail(uid, email); this.metrics?.increment('db.primaryEmail.set', { result: 'success' }); return account; } catch (err) { if (isNotFoundError(err)) { this.metrics?.increment('db.primaryEmail.set', { result: 'notFound', }); throw error.unknownAccount(email); } this.metrics?.increment('db.primaryEmail.set', { result: 'error' }); throw err; } } async updatePasswordForgotToken(token: { id: string; uid: string; tries: number; }) { log.trace('DB.udatePasswordForgotToken', { uid: token && token.uid }); const { id } = token; this.metrics?.increment('db.passwordForgotToken.update'); return RawPasswordForgotToken.update(id, token.tries); } /** * Update cached session-token data, such as timestamps * and device info. This is a comparatively cheap call that * only writes to redis, not the underlying DB, and hence * can be safely used in frequently-called routes. * * To do a more expensive write that flushes to the underlying * DB, use updateSessionToken instead. */ async touchSessionToken( token: any, geo: any, onlyUpdateLastAccessTime = false ) { const { id, uid } = token; log.trace('DB.touchSessionToken', { id, uid }); if (!this.redis || !features.isLastAccessTimeEnabledForUser(uid)) { return; } let t; if (onlyUpdateLastAccessTime) { t = { lastAccessTime: token.lastAccessTime, id, }; } else { let location; if (geo && geo.location) { location = { city: geo.location.city, country: geo.location.country, countryCode: geo.location.countryCode, state: geo.location.state, stateCode: geo.location.stateCode, }; } t = { lastAccessTime: token.lastAccessTime, location, uaBrowser: token.uaBrowser, uaBrowserVersion: token.uaBrowserVersion, uaDeviceType: token.uaDeviceType, uaFormFactor: token.uaFormFactor, uaOS: token.uaOS, uaOSVersion: token.uaOSVersion, id, }; } return this.redis.touchSessionToken(uid, t); } /** * Persist updated session-token data to the database. * This is a comparatively expensive call that writes through * to the underlying DB and hence should not be used in * frequently-called routes. * * To do a cheaper write of transient metadata that only hits * redis, use touchSessionToken instead. */ async updateSessionToken(sessionToken: any, geo: any) { const { id, uid, lastAccessTime, lastAccessTimeEnabled } = sessionToken; // Just for connection pool issue investigation. Make sure the last access time is set to something realistic. log.debug('DB.updateSessionToken', { id, uid, lastAccessTime, lastAccessTimeEnabled, }); await this.touchSessionToken(sessionToken, geo); await RawSessionToken.update({ id, ...sessionToken }); this.metrics?.increment('db.sessionToken.update'); } async pruneSessionTokens(uid: string, sessionTokens: any) { log.debug('DB.pruneSessionTokens', { uid, tokenCount: sessionTokens.length, }); if ( !this.redis || !TOKEN_PRUNING_ENABLED || !features.isLastAccessTimeEnabledForUser(uid) ) { return; } const tokenIds = sessionTokens .filter( (token: any) => token.createdAt <= Date.now() - TOKEN_PRUNING_MAX_AGE ) .map((token: any) => token.id); if (tokenIds.length === 0) { return; } return this.redis.pruneSessionTokens(uid, tokenIds); } async createDevice(uid: string, deviceInfo: any): Promise<any> { log.trace('DB.createDevice', { uid: uid, id: deviceInfo.id }); const sessionTokenId = deviceInfo.sessionTokenId; const refreshTokenId = deviceInfo.refreshTokenId; const id = await random.hex(16); deviceInfo.id = id; deviceInfo.createdAt = Date.now(); try { await Device.create({ ...deviceInfo, sessionTokenId, refreshTokenId, uid, }); } catch (err) { if (isRecordAlreadyExistsError(err)) { const devices = await this.devices(uid); // It's possible (but extraordinarily improbable) that we generated // a duplicate device id, so check the devices for this account. If // we find a duplicate, retry with a new id. If we don't find one, // the problem was caused by the unique sessionToken or // refreshToken constraint so return an appropriate error. const duplicateDevice = devices.find( (device: any) => device.id === deviceInfo.id ); if (duplicateDevice) { this.metrics?.increment('db.device.create', { result: 'duplicate', }); return this.createDevice(uid, deviceInfo); } const conflictingDevice = devices.find( (device: any) => (sessionTokenId && device.sessionTokenId === sessionTokenId) || (refreshTokenId && device.refreshTokenId === refreshTokenId) ); this.metrics?.increment('db.device.create', { result: 'conflict', }); throw error.deviceSessionConflict(conflictingDevice?.id); } this.metrics?.increment('db.device.create', { result: 'error' }); throw err; } deviceInfo.pushEndpointExpired = false; this.metrics?.increment('db.device.create', { result: 'success' }); return deviceInfo; } async updateDevice(uid: string, deviceInfo: any) { const sessionTokenId = deviceInfo.sessionTokenId; try { await Device.update({ uid, callbackIsExpired: deviceInfo.pushEndpointExpired, ...deviceInfo, }); } catch (err) { if (isNotFoundError(err)) { this.metrics?.increment('db.device.update', { result: 'notfound' }); throw error.unknownDevice(); } if (isRecordAlreadyExistsError(err)) { // Identify the conflicting device in the error response, // to save a server round-trip for the client. const devices = await this.devices(uid); const conflictingDevice = devices.find( (device: any) => device.sessionTokenId === sessionTokenId ); this.metrics?.increment('db.device.update', { result: 'conflict', }); throw error.deviceSessionConflict(conflictingDevice?.id); } this.metrics?.increment('db.device.update', { result: 'error' }); throw err; } this.metrics?.increment('db.device.update', { result: 'success' }); return deviceInfo; } // DELETE async deleteAccount(authToken: { uid: string }) { const { uid } = authToken; log.info('DB.deleteAccount', { uid }); if (this.redis) { await this.redis.del(uid); } this.metrics?.increment('db.account.delete'); return Account.delete(uid); } async deleteSessionToken(sessionToken: { id: string; uid: string }) { const { id, uid } = sessionToken; log.trace('DB.deleteSessionToken', { id, uid }); await this.deleteSessionTokenFromRedis(uid, id); this.metrics?.increment('db.sessionToken.delete'); return RawSessionToken.delete(id); } async deleteKeyFetchToken(keyFetchToken: { id: string; uid: string }) { const { id, uid } = keyFetchToken; log.trace('DB.deleteKeyFetchToken', { id, uid }); this.metrics?.increment('db.keyFetchToken.delete'); return RawKeyFetchToken.delete(id); } async deleteAccountResetToken(accountResetToken: { id: string; uid: string; }) { const { id, uid } = accountResetToken; log.trace('DB.deleteAccountResetToken', { id, uid }); this.metrics?.increment('db.accountResetToken.delete'); return RawAccountResetToken.delete(id); } async deletePasswordForgotToken(passwordForgotToken: { id: string; uid: string; }) { const { id, uid } = passwordForgotToken; log.trace('DB.deletePasswordForgotToken', { id, uid }); this.metrics?.increment('db.passwordForgotToken.delete'); return RawPasswordForgotToken.delete(id); } async deletePasswordChangeToken(passwordChangeToken: { id: string; uid: string; }) { const { id, uid } = passwordChangeToken; log.trace('DB.deletePasswordChangeToken', { id, uid }); this.metrics?.increment('db.passwordChangeToken.delete'); return RawPasswordChangeToken.delete(id); } async deleteDevice(uid: string, deviceId: string) { log.trace('DB.deleteDevice', { uid, id: deviceId }); try { const result = await Device.delete(uid, deviceId); await this.deleteSessionTokenFromRedis(uid, result.sessionTokenId); this.metrics?.increment('db.device.delete', { result: 'success' }); return result; } catch (err) { if (isNotFoundError(err)) { this.metrics?.increment('db.device.delete', { result: 'notfound' }); throw error.unknownDevice(); } this.metrics?.increment('db.device.delete', { result: 'error' }); throw err; } } // BATCH async resetAccount( accountResetToken: any, data: any, keepSessions = false ) { const { uid } = accountResetToken; log.trace('DB.resetAccount', { uid }); if (this.redis && keepSessions !== true) { await this.redis.del(uid); } data.verifierSetAt = Date.now(); if (data.verifyHashVersion2 != null) { this.metrics?.increment('reset.account.v2'); } else { this.metrics?.increment('reset.account.v1'); } return Account.reset({ uid, ...data }); } async verifyEmail(account: { uid: string }, emailCode: string) { const { uid } = account; log.trace('DB.verifyEmail', { uid, emailCode }); this.metrics?.increment('db.verify.email'); await Account.verifyEmail(uid, emailCode); } async verifyTokens(tokenVerificationId: string, accountData: any) { log.trace('DB.verifyTokens', { tokenVerificationId }); try { await BaseToken.verifyToken(accountData.uid, tokenVerificationId); this.metrics?.increment('db.verify.tokens', { result: 'success' }); } catch (err) { if (isNotFoundError(err)) { this.metrics?.increment('db.verify.tokens', { result: 'notfound', }); throw error.invalidVerificationCode(); } this.metrics?.increment('db.verify.tokens', { result: 'error' }); throw err; } } async verifyTokensWithMethod( tokenId: string, verificationMethod: VerificationMethod | number ) { log.trace('DB.verifyTokensWithMethod', { tokenId, verificationMethod }); this.metrics?.increment('db.verify.tokensWithMethod', { method: verificationMethodToString(verificationMethod), }); await RawSessionToken.verify(tokenId, verificationMethod); } async verifyPasswordForgotTokenWithMethod( tokenId: string, verificationMethod: VerificationMethod | number ) { log.trace('DB.verifyPasswordForgotTokenWithMethod', { tokenId, verificationMethod, }); this.metrics?.increment('db.verify.passwordForgotTokensWithMethod', { method: verificationMethodToString(verificationMethod), }); await RawPasswordForgotToken.updateVerificationMethod( tokenId, verificationMethod ); } async forgotPasswordVerified(passwordForgotToken: { id: string; uid: string; verificationMethod: VerificationMethod | number; }) { const { id, uid, verificationMethod } = passwordForgotToken; log.trace('DB.forgotPasswordVerified', { uid }); const accountResetToken = await AccountResetToken.create({ uid, verificationMethod, }); await RawPasswordForgotToken.verify(id, accountResetToken); this.metrics?.increment('db.forgotPasswordVerified'); return accountResetToken; } async createPassword( uid: string, authSalt: string, clientSalt: string | undefined, verifyHash: string, verifyHashVersion2: string | undefined, wrapWrapKb: string, wrapWrapKbVersion2: string | undefined, verifierVersion: number ) { log.trace('DB.createPassword', { uid }); if (clientSalt && verifyHashVersion2 && wrapWrapKbVersion2) { this.metrics?.increment('create.password.v2'); } else { this.metrics?.increment('create.password.v1'); } return Account.createPassword( uid, authSalt, clientSalt, verifyHash, verifyHashVersion2, wrapWrapKb, wrapWrapKbVersion2, verifierVersion ); } async updateLocale(uid: string, locale: string) { log.trace('DB.updateLocale', { uid, locale }); this.metrics?.increment('db.updateLocale'); return Account.updateLocale(uid, locale); } async securityEvent(event: any) { log.trace('DB.securityEvent', { securityEvent: event, }); await SecurityEvent.create({ ...event, ipHmacKey: config.securityHistory.ipHmacKey, }); } async securityEvents(params: { uid: string; ipAddr: string }) { log.trace('DB.securityEvents', { params: params, }); const { uid, ipAddr } = params; return SecurityEvent.findByUidAndIP( uid, ipAddr, config.securityHistory.ipHmacKey ); } async verifiedLoginSecurityEvents(params: { uid: string; ipAddr: string }) { log.trace('DB.verifiedLoginSecurityEvents', { params: params, }); const { uid, ipAddr } = params; return SecurityEvent.findByUidAndIPAndVerifiedLogin( uid, ipAddr, config.securityHistory.ipHmacKey ); } async securityEventsByUid(params: { uid: string }) { log.trace('DB.securityEventsByUid', { params: params, }); const { uid } = params; return SecurityEvent.findByUid(uid); } async createUnblockCode(uid: string): Promise<any> { if (!UnblockCode) { throw new Error('Unblock has not been configured'); } log.trace('DB.createUnblockCode', { uid }); const code = await UnblockCode(); try { await Account.createUnblockCode(uid, code); this.metrics?.increment('db.unblockCode.create', { result: 'success' }); return code; } catch (err) { // duplicates should be super rare, but it's feasible that a // uid already has an existing unblockCode. Just try again. if (isRecordAlreadyExistsError(err)) { log.error('DB.createUnblockCode.duplicate', { err: err, uid: uid, }); this.metrics?.increment('db.unblockCode.create', { result: 'duplicate', }); return this.createUnblockCode(uid); } this.metrics?.increment('db.unblockCode.create', { result: 'error' }); throw err; } } async consumeUnblockCode(uid: string, code: string) { log.trace('DB.consumeUnblockCode', { uid }); try { const result = await Account.consumeUnblockCode(uid, code); this.metrics?.increment('db.unblockCode.consume', { result: 'success', }); return result; } catch (err) { if (isNotFoundError(err)) { this.metrics?.increment('db.unblockCode.consume', { result: 'invalid', }); throw error.invalidUnblockCode(); } this.metrics?.increment('db.unblockCode.consume', { result: 'error' }); throw err; } } async createEmailBounce( bounceData: Parameters<typeof EmailBounce.create>[0] ) { log.trace('DB.createEmailBounce', { bounceData: bounceData, }); this.metrics?.increment('db.emailBounce.create'); await EmailBounce.create(bounceData); } async createEmail(uid: string, emailData: any) { log.trace('DB.createEmail', { email: emailData.email, uid, }); try { await Account.createEmail({ uid, ...emailData }); this.metrics?.increment('db.email.create', { result: 'success' }); } catch (err) { if (isEmailAlreadyExistsError(err)) { this.metrics?.increment('db.email.create', { result: 'duplicate' }); throw error.emailExists(); } this.metrics?.increment('db.email.create', { result: 'error' }); throw err; } } async deleteEmail(uid: string, email: string) { log.trace('DB.deleteEmail', { uid }); try { const result = await Account.deleteEmail(uid, email); this.metrics?.increment('db.email.delete', { result: 'success' }); return result; } catch (err) { if (isEmailDeletePrimaryError(err)) { this.metrics?.increment('db.email.delete', { result: 'noDeletePrimary', }); throw error.cannotDeletePrimaryEmail(); } this.metrics?.increment('db.email.delete', { result: 'error' }); throw err; } } async createSigninCode(uid: string, flowId: string): Promise<any> { log.trace('DB.createSigninCode'); const code = await random.hex(config.signinCodeSize); try { await Account.createSigninCode(uid, code, flowId); } catch (err) { if (isRecordAlreadyExistsError(err)) { log.warn('DB.createSigninCode.duplicate'); this.metrics?.increment('db.signinCode.create', { result: 'alreadyExists', }); return this.createSigninCode(uid, flowId); } this.metrics?.increment('db.signinCode.create', { result: 'error' }); throw err; } this.metrics?.increment('db.signinCode.create', { result: 'success' }); return code; } async consumeSigninCode(code: string) { log.trace('DB.consumeSigninCode', { code }); try { const result = await Account.consumeSigninCode(code); this.metrics?.increment('db.signinCode.consume', { result: 'success' }); return result; } catch (err) { if (isNotFoundError(err)) { this.metrics?.increment('db.signinCode.consume', { result: 'invalid', }); throw error.invalidSigninCode(); } this.metrics?.increment('db.signinCode.consume', { result: 'error' }); throw err; } } async resetAccountTokens(uid: string) { log.trace('DB.resetAccountTokens', { uid }); this.metrics?.increment('db.resetAccountTokens'); await Account.resetTokens(uid); } async createTotpToken(uid: string, sharedSecret: string, epoch: number) { log.trace('DB.createTotpToken', { uid }); try { await TotpToken.create({ uid, sharedSecret, epoch, }); this.metrics?.increment('db.totpToken.create', { result: 'success' }); } catch (err) { if (isRecordAlreadyExistsError(err)) { this.metrics?.increment('db.totpToken.create', { result: 'alreadyExists', }); throw error.totpTokenAlreadyExists(); } this.metrics?.increment('db.totpToken.create', { result: 'error' }); throw err; } } async deleteTotpToken(uid: string) { log.trace('DB.deleteTotpToken', { uid }); try { const result = await TotpToken.delete(uid); this.metrics?.increment('db.totpToken.delete', { result: 'success' }); return result; } catch (err) { if (isNotFoundError(err)) { this.metrics?.increment('db.totpToken.delete', { result: 'notFound', }); throw error.totpTokenNotFound(); } this.metrics?.increment('db.totpToken.delete', { result: 'error' }); throw err; } } async updateTotpToken( uid: string, data: { verified: boolean; enabled: boolean } ) { log.trace('DB.updateTotpToken', { uid, data }); try { await TotpToken.update(uid, data.verified, data.enabled); this.metrics?.increment('db.totpToken.update', { result: 'success' }); } catch (err) { if (isNotFoundError(err)) { this.metrics?.increment('db.totpToken.update', { result: 'notFound', }); throw error.totpTokenNotFound(); } this.metrics?.increment('db.totpToken.update', { result: 'error' }); throw err; } } async replaceRecoveryCodes(uid: string, count: number) { log.trace('DB.replaceRecoveryCodes', { uid }); const codes = await this.createRecoveryCodes(uid, count); await this.updateRecoveryCodes(uid, codes); this.metrics?.increment('db.recoveryCodes.replace'); return codes; } async createRecoveryCodes(uid: string, count: number) { log.trace('DB.createRecoveryCodes', { uid }); const getCode = base32(config.totp.recoveryCodes.length); const codes = await Promise.all( Array.from({ length: count }, async () => { return (await getCode()).toLowerCase(); }) ); this.metrics?.increment('db.recoveryCodes.create'); return codes; } async updateRecoveryCodes(uid: string, codes: string[]) { log.trace('DB.updateRecoveryCodes', { uid, codes }); // Convert codes into hashes const hashes = await Promise.all( codes.map(async (code: string) => { // eslint-disable-next-line fxa/async-crypto-random const salt = crypto.randomBytes(32); const hash = Buffer.from( await scrypt.hash(Buffer.from(code), salt, 65536, 8, 1, 32), 'hex' ); return { salt, hash, }; }) ); await Account.replaceRecoveryCodes(uid, hashes); this.metrics?.increment('db.recoveryCodes.update'); } async consumeRecoveryCode(uid: string, code: string) { log.trace('DB.consumeRecoveryCode', { uid }); const codeBuffer = Buffer.from(code.toLowerCase()); const codeChecker = async (hash: any, salt: any) => { return crypto.timingSafeEqual( hash, Buffer.from( await scrypt.hash(codeBuffer, salt, 65536, 8, 1, 32), 'hex' ) as any ); }; try { const remaining = await Account.consumeRecoveryCode(uid, codeChecker); this.metrics?.increment('db.recoveryCodes.consume', { result: 'success', }); return { remaining }; } catch (err) { if (isNotFoundError(err)) { this.metrics?.increment('db.recoveryCodes.consume', { result: 'notFound', }); throw error.recoveryCodeNotFound(); } this.metrics?.increment('db.recoveryCodes.consume', { result: 'error', }); throw err; } } async createRecoveryKey( uid: string, recoveryKeyId: string, recoveryData: string, enabled: boolean ) { log.trace('DB.createRecoveryKey', { uid }); try { await RecoveryKey.create({ uid, recoveryKeyId, recoveryData, enabled }); this.metrics?.increment('db.recoveryKey.create', { result: 'success' }); } catch (err) { if (isRecordAlreadyExistsError(err)) { this.metrics?.increment('db.recoveryKey.create', { result: 'alreadyExists', }); throw error.recoveryKeyExists(); } this.metrics?.increment('db.recoveryKey.create', { result: 'error' }); throw err; } } async deleteRecoveryKey(uid: string) { log.trace('DB.deleteRecoveryKey', { uid }); this.metrics?.increment('db.recoveryKey.delete'); return RecoveryKey.delete(uid); } async updateRecoveryKey( uid: string, recoveryKeyId: string, enabled: boolean ) { log.trace('DB.updateRecoveryKey', { uid }); this.metrics?.increment('db.recoveryKey.update'); return RecoveryKey.update({ uid, recoveryKeyId, enabled }); } async getRecoveryKeyRecordWithHint(uid: string) { log.trace('DB.getRecoveryKeyRecordWithHint', { uid }); this.metrics?.increment('db.recoveryKey.getWithHint'); return await RecoveryKey.findRecordWithHintByUid(uid); } async updateRecoveryKeyHint(uid: string, hint: string) { log.trace('DB.updateRecoveryKeyHint', { uid, hint }); this.metrics?.increment('db.recoveryKey.updateHint'); return RecoveryKey.updateRecoveryKeyHint(uid, hint); } async deleteSessionTokenFromRedis(uid: string, id: string) { if (!this.redis) { return; } return this.redis.pruneSessionTokens(uid, [id]); } } return DB; }; export type DB = InstanceType<ReturnType<typeof createDB>>;