packages/fxa-auth-server/lib/routes/recovery-codes.js (219 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/. */ 'use strict'; const errors = require('../error'); const isA = require('joi'); const validators = require('./validators'); const { Container } = require('typedi'); const RECOVERY_CODES_DOCS = require('../../docs/swagger/recovery-codes-api').default; const { BackupCodeManager } = require('@fxa/accounts/two-factor'); const { recordSecurityEvent } = require('./utils/security-event'); const RECOVERY_CODE_SANE_MAX_LENGTH = 20; module.exports = (log, db, config, customs, mailer, glean) => { const codeConfig = config.recoveryCodes; const RECOVERY_CODE_COUNT = (codeConfig && codeConfig.count) || 8; const backupCodeManager = Container.get(BackupCodeManager); // Validate backup authentication codes const recoveryCodesSchema = validators.recoveryCodes( RECOVERY_CODE_COUNT, RECOVERY_CODE_SANE_MAX_LENGTH ); return [ { method: 'GET', path: '/recoveryCodes', options: { ...RECOVERY_CODES_DOCS.RECOVERYCODES_GET, auth: { strategy: 'sessionToken', }, response: { schema: recoveryCodesSchema, }, }, async handler(request) { log.begin('replaceRecoveryCodes', request); const { authenticatorAssuranceLevel, uid } = request.auth.credentials; // Since TOTP and backup authentication codes go hand in hand, you should only be // able to replace backup authentication codes in a TOTP verified session. if (!authenticatorAssuranceLevel || authenticatorAssuranceLevel <= 1) { throw errors.unverifiedSession(); } const recoveryCodes = await db.replaceRecoveryCodes( uid, RECOVERY_CODE_COUNT ); recordSecurityEvent('account.recovery_codes_replaced', { db, request, }); const account = await db.account(uid); const { acceptLanguage, clientAddress: geo, ua } = request.app; await mailer.sendPostNewRecoveryCodesEmail(account.emails, account, { acceptLanguage, timeZone: geo.timeZone, uaBrowser: ua.browser, uaBrowserVersion: ua.browserVersion, uaOS: ua.os, uaOSVersion: ua.osVersion, uaDeviceType: ua.deviceType, uid, }); log.info('account.recoveryCode.replaced', { uid }); await request.emitMetricsEvent('recoveryCode.replaced', { uid }); return { recoveryCodes }; }, }, { method: 'PUT', path: '/recoveryCodes', options: { ...RECOVERY_CODES_DOCS.RECOVERY_CODES_PUT, auth: { strategy: 'sessionToken', }, validate: { payload: recoveryCodesSchema, }, response: { schema: isA.object({ success: isA.boolean(), }), }, }, async handler(request) { log.begin('updateRecoveryCodes', request); const { authenticatorAssuranceLevel, uid } = request.auth.credentials; // Since TOTP and backup authentication codes go hand in hand, you should only be // able to replace backup authentication codes in a TOTP verified session. if (!authenticatorAssuranceLevel || authenticatorAssuranceLevel <= 1) { throw errors.unverifiedSession(); } const { recoveryCodes } = request.payload; await db.updateRecoveryCodes(uid, recoveryCodes); glean.twoFactorAuth.replaceCodeComplete(request, { uid }); const account = await db.account(uid); const { acceptLanguage, clientAddress: geo, ua } = request.app; await mailer.sendPostNewRecoveryCodesEmail(account.emails, account, { acceptLanguage, timeZone: geo.timeZone, uaBrowser: ua.browser, uaBrowserVersion: ua.browserVersion, uaOS: ua.os, uaOSVersion: ua.osVersion, uaDeviceType: ua.deviceType, uid, }); recordSecurityEvent('account.recovery_codes_created', { db, request, account, }); log.info('account.recoveryCode.replaced', { uid }); await request.emitMetricsEvent('recoveryCode.replaced', { uid }); return { success: true }; }, }, { method: 'GET', path: '/recoveryCodes/exists', options: { auth: { strategy: 'sessionToken', payload: 'required', }, response: { schema: isA.object({ hasBackupCodes: isA.boolean().optional(), count: isA.number().optional(), }), }, }, async handler(request) { log.begin('checkRecoveryCodesExist', request); const { uid } = request.auth.credentials; const { hasBackupCodes, count } = await backupCodeManager.getCountForUserId(uid); log.info('account.recoveryCode.existsChecked', { uid, hasBackupCodes, count, }); return { hasBackupCodes, count }; }, }, { method: 'POST', path: '/session/verify/recoveryCode', options: { ...RECOVERY_CODES_DOCS.SESSION_VERIFY_RECOVERYCODE_POST, auth: { strategy: 'sessionToken', payload: 'required', }, validate: { payload: isA.object({ // Validation here is done with BASE_36 superset to be backwards compatible... // Ideally all backup authentication codes are Crockford Base32. code: validators.recoveryCode( RECOVERY_CODE_SANE_MAX_LENGTH, validators.BASE_36 ), }), }, response: { schema: isA.object({ remaining: isA.number(), }), }, }, async handler(request) { log.begin('session.verify.recoveryCode', request); const { email, id: tokenId, tokenVerificationId, uid, } = request.auth.credentials; await customs.check(request, email, 'verifyRecoveryCode'); const { code } = request.payload; const { remaining } = await db.consumeRecoveryCode(uid, code); if (remaining === 0) { log.info('account.recoveryCode.consumedAllCodes', { uid }); } if (tokenVerificationId) { await db.verifyTokensWithMethod(tokenId, 'recovery-code'); } const account = await db.account(uid); const { acceptLanguage, clientAddress: ip, geo, ua } = request.app; const mailerPromises = [ mailer.sendPostSigninRecoveryCodeEmail(account.emails, account, { acceptLanguage, ip, location: geo.location, timeZone: geo.timeZone, uaBrowser: ua.browser, uaBrowserVersion: ua.browserVersion, uaOS: ua.os, uaOSVersion: ua.osVersion, uaDeviceType: ua.deviceType, uid, }), ]; if (remaining <= codeConfig.notifyLowCount) { log.info('account.recoveryCode.notifyLowCount', { uid, remaining }); mailerPromises.push( mailer.sendLowRecoveryCodesEmail(account.emails, account, { acceptLanguage, numberRemaining: remaining, uid, }) ); } await Promise.allSettled(mailerPromises); log.info('account.recoveryCode.verified', { uid }); await request.emitMetricsEvent('recoveryCode.verified', { uid }); // this signals the end of the login flow await request.emitMetricsEvent('account.confirmed', { uid }); glean.login.recoveryCodeSuccess(request, { uid }); recordSecurityEvent('account.recovery_codes_signin_complete', { db, request, account, }); return { remaining }; }, }, ]; };