packages/fxa-auth-server/lib/routes/totp.js (516 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 validators = require('./validators'); const isA = require('joi'); const otplib = require('otplib'); const qrcode = require('qrcode'); const { promisify } = require('util'); const METRICS_CONTEXT_SCHEMA = require('../metrics/context').schema; const TOTP_DOCS = require('../../docs/swagger/totp-api').default; const DESCRIPTION = require('../../docs/swagger/shared/descriptions').default; const { Container } = require('typedi'); const { RecoveryPhoneService, RecoveryNumberNotExistsError, RecoveryNumberRemoveMissingBackupCodes, } = require('@fxa/accounts/recovery-phone'); const { BackupCodeManager } = require('@fxa/accounts/two-factor'); const { recordSecurityEvent } = require('./utils/security-event'); const RECOVERY_CODE_SANE_MAX_LENGTH = 20; module.exports = ( log, db, mailer, customs, config, glean, profileClient, environment, statsd ) => { const otpUtils = require('../../lib/routes/utils/otp')( log, config, db, statsd ); // Currently, QR codes are rendered with the highest possible // error correction, which should in theory allow clients to // scan the image better. // Ref: https://github.com/soldair/node-qrcode#error-correction-level const qrCodeOptions = { errorCorrectionLevel: 'H' }; const RECOVERY_CODE_COUNT = (config.recoveryCodes && config.recoveryCodes.count) || 8; const codeConfig = config.recoveryCodes; promisify(qrcode.toDataURL); const recoveryPhoneService = Container.get(RecoveryPhoneService); const backupCodeManager = Container.get(BackupCodeManager); // This helps us distinguish between testing environments and // totp codes per environment. const service = !environment?.startsWith('prod') ? `${config.serviceName} - ${environment}` : `${config.serviceName}`; return [ { method: 'POST', path: '/totp/create', options: { ...TOTP_DOCS.TOTP_CREATE_POST, auth: { strategy: 'sessionToken', payload: 'required', }, validate: { payload: isA.object({ metricsContext: METRICS_CONTEXT_SCHEMA, }), }, response: { schema: isA.object({ qrCodeUrl: isA.string().required(), secret: isA.string().required(), recoveryCodes: isA.array().items(isA.string()).required(), }), }, }, handler: async function (request) { log.begin('totp.create', request); const sessionToken = request.auth.credentials; const uid = sessionToken.uid; await customs.check(request, sessionToken.email, 'totpCreate'); if (sessionToken.tokenVerificationId) { throw errors.unverifiedSession(); } // Default options for TOTP const otpOptions = { encoding: 'hex', step: config.step, window: config.window, }; const authenticator = new otplib.authenticator.Authenticator(); authenticator.options = Object.assign( {}, otplib.authenticator.options, otpOptions ); const secret = authenticator.generateSecret(); try { await db.createTotpToken(uid, secret, 0); } catch (e) { if (e.errno === errors.ERRNO.TOTP_TOKEN_EXISTS) { const hasEnabledToken = await otpUtils.hasTotpToken({ uid }); if (hasEnabledToken) { throw e; } await db.deleteTotpToken(uid); await db.createTotpToken(uid, secret, 0); } } log.info('totpToken.created', { uid }); await request.emitMetricsEvent('totpToken.created', { uid }); const otpauth = authenticator.keyuri( sessionToken.email, service, secret ); const qrCodeUrl = await qrcode.toDataURL(otpauth, qrCodeOptions); const recoveryCodes = await db.replaceRecoveryCodes( uid, RECOVERY_CODE_COUNT ); return { qrCodeUrl, secret, recoveryCodes, }; }, }, { method: 'POST', path: '/totp/destroy', options: { ...TOTP_DOCS.TOTP_DESTROY_POST, auth: { strategy: 'sessionToken', }, response: {}, }, handler: async function (request) { log.begin('totp.destroy', request); const sessionToken = request.auth.credentials; const { uid } = sessionToken; await customs.check(request, sessionToken.email, 'totpDestroy'); // If a TOTP token is not verified, we should be able to safely delete regardless of session // verification state. const hasEnabledToken = await otpUtils.hasTotpToken({ uid }); // To help prevent users from getting locked out of their account, sessions created and verified // before TOTP was enabled, can remove TOTP. Any new sessions after TOTP is enabled, are only considered // verified *if and only if* they have verified a TOTP code. if (!sessionToken.tokenVerified) { throw errors.unverifiedSession(); } await db.deleteTotpToken(uid); // Downgrade the session to email-based verification when TOTP is // removed. Because we know the session is already verified, there's // no security risk in setting it as verified using a different method. // See #5154. await db.verifyTokensWithMethod(sessionToken.id, 'email-2fa'); await profileClient.deleteCache(uid); await log.notifyAttachedServices( 'profileDataChange', {}, { uid, } ); if (hasEnabledToken) { const account = await db.account(uid); const geoData = request.app.geo; const emailOptions = { acceptLanguage: request.app.acceptLanguage, timeZone: geoData.timeZone, uaBrowser: request.app.ua.browser, uaBrowserVersion: request.app.ua.browserVersion, uaOS: request.app.ua.os, uaOSVersion: request.app.ua.osVersion, uaDeviceType: request.app.ua.deviceType, uid, }; try { await mailer.sendPostRemoveTwoStepAuthenticationEmail( account.emails, account, emailOptions ); } catch (err) { // If email fails, log the error without aborting the operation. log.error('mailer.sendPostRemoveTwoStepAuthenticationEmail', { err, }); } } recordSecurityEvent('account.two_factor_removed', { db, request, }); // Clean up the recovery phone if it was registered. // Don't fail if this doesn't work, but monitor success rate with stats. try { const success = await recoveryPhoneService.removePhoneNumber(uid); if (success) { statsd.increment('totp.destroy.remove_phone_number.success'); await glean.twoStepAuthPhoneRemove.success(request); } else { statsd.increment('totp.destroy.remove_phone_number.fail'); } } catch (error) { if ( error instanceof RecoveryNumberNotExistsError || error instanceof RecoveryNumberRemoveMissingBackupCodes ) { statsd.increment('totp.destroy.remove_phone_number.fail'); } else { statsd.increment('totp.destroy.remove_phone_number.error'); log.error('totp.destroy.remove_phone_number.error', error); } } // Clean up any associated backup codes. // Again, don't fail if errors out, but monitor with stats. try { const success = await backupCodeManager.deleteRecoveryCodes(uid); if (success) { statsd.increment('totp.destroy.delete_recovery_codes.success'); } else { statsd.increment('totp.destroy.delete_recovery_codes.fail'); } } catch (error) { statsd.increment('totp.destroy.delete_recovery_codes.error'); log.error('totp.destroy.delete_recovery_codes.error', error); } // Record that the 2fa was successfully removed glean.twoStepAuthRemove.success(request, { uid }); return {}; }, }, { method: 'GET', path: '/totp/exists', options: { ...TOTP_DOCS.TOTP_EXISTS_GET, auth: { strategies: [ 'multiStrategySessionToken', 'multiStrategyPasswordForgotToken', ], }, response: { schema: isA.object({ exists: isA.boolean(), verified: isA.boolean(), }), }, }, handler: async function (request) { log.begin('totp.exists', request); const sessionToken = request.auth.credentials; try { const token = await db.totpToken(sessionToken.uid); return { exists: true, verified: !!token.verified, }; } catch (err) { if (err.errno === errors.ERRNO.TOTP_TOKEN_NOT_FOUND) { return { exists: false, verified: false, }; } else { throw err; } } }, }, { method: 'POST', path: '/totp/verify', options: { ...TOTP_DOCS.TOTP_VERIFY_POST, auth: { strategy: 'passwordForgotToken', payload: 'required', }, validate: { payload: isA.object({ code: isA .string() .max(32) .regex(validators.DIGITS) .required() .description(DESCRIPTION.codeTotp), }), }, response: { schema: isA.object({ success: isA.boolean(), }), }, }, handler: async function (request) { log.begin('totp.verify', request); const code = request.payload.code; const passwordForgotToken = request.auth.credentials; await customs.checkAuthenticated( request, passwordForgotToken.uid, 'verifyTotpCode' ); try { const totpRecord = await db.totpToken(passwordForgotToken.uid); const sharedSecret = totpRecord.sharedSecret; // Default options for TOTP const otpOptions = { encoding: 'hex', step: config.step, window: config.window, }; const isValidCode = otpUtils.verifyOtpCode( code, sharedSecret, otpOptions, 'totp.verify' ); if (isValidCode) { glean.resetPassword.twoFactorSuccess(request, { uid: passwordForgotToken.uid, }); await db.verifyPasswordForgotTokenWithMethod( passwordForgotToken.id, 'totp-2fa' ); } return { success: isValidCode, }; } catch (err) { if (err.errno === errors.ERRNO.TOTP_TOKEN_NOT_FOUND) { return { success: false }; } else { throw err; } } }, }, // this endpoint is used in the password reset flow only { method: 'POST', path: '/totp/verify/recoveryCode', options: { ...TOTP_DOCS.TOTP_VERIFY_RECOVERY_CODE_POST, auth: { strategy: 'passwordForgotToken', }, 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(), }), }, }, handler: async function (request) { log.begin('totp.verify.recoveryCode', request); const code = request.payload.code; const { uid, email } = request.auth.credentials; const passwordForgotToken = request.auth.credentials; await customs.check(request, email, 'verifyRecoveryCode'); const account = await db.account(uid); const { acceptLanguage, clientAddress: ip, geo, ua } = request.app; const { remaining } = await db.consumeRecoveryCode(uid, code); const mailerPromises = [ mailer.sendPostConsumeRecoveryCodeEmail(account.emails, account, { acceptLanguage, ip, location: geo.location, numberRemaining: remaining, 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.all(mailerPromises); glean.resetPassword.twoFactorRecoveryCodeSuccess(request, { uid, }); await db.verifyPasswordForgotTokenWithMethod( passwordForgotToken.id, 'recovery-code' ); return { remaining, }; }, }, { method: 'POST', path: '/session/verify/totp', options: { ...TOTP_DOCS.SESSION_VERIFY_TOTP_POST, auth: { strategy: 'sessionToken', payload: 'required', }, validate: { payload: isA.object({ code: isA .string() .max(32) .regex(validators.DIGITS) .required() .description(DESCRIPTION.codeTotp), service: validators.service, }), }, response: { schema: isA.object({ success: isA.boolean().required(), }), }, }, handler: async function (request) { log.begin('session.verify.totp', request); const code = request.payload.code; const sessionToken = request.auth.credentials; const { uid, email } = sessionToken; await customs.checkAuthenticated(request, uid, 'verifyTotpCode'); const token = await db.totpToken(sessionToken.uid); const sharedSecret = token.sharedSecret; const tokenVerified = token.verified; // Default options for TOTP const otpOptions = { encoding: 'hex', step: config.step, window: config.window, }; const isValidCode = otpUtils.verifyOtpCode( code, sharedSecret, otpOptions, 'session.verify' ); // Once a valid TOTP code has been detected, the token becomes verified // and enabled for the user. if (isValidCode && !tokenVerified) { await db.updateTotpToken(sessionToken.uid, { verified: true, enabled: true, }); recordSecurityEvent('account.two_factor_added', { db, request, }); glean.twoFactorAuth.codeComplete(request, { uid }); await profileClient.deleteCache(uid); await log.notifyAttachedServices('profileDataChange', request, { uid, }); } // If a valid code was sent, this verifies the session using the `totp-2fa` method. if (isValidCode && sessionToken.authenticatorAssuranceLevel <= 1) { await db.verifyTokensWithMethod(sessionToken.id, 'totp-2fa'); } if (isValidCode) { log.info('totp.verified', { uid }); await request.emitMetricsEvent('totpToken.verified', { uid }); // this signals the end of the login flow await request.emitMetricsEvent('account.confirmed', { uid }); glean.login.totpSuccess(request, { uid }); recordSecurityEvent('account.two_factor_challenge_success', { db, request, }); } else { log.info('totp.unverified', { uid }); glean.login.totpFailure(request, { uid }); await customs.flag(request.app.clientAddress, { email, errno: errors.ERRNO.INVALID_EXPIRED_OTP_CODE, }); await request.emitMetricsEvent('totpToken.unverified', { uid }); recordSecurityEvent('account.two_factor_challenge_failure', { db, request, }); } await sendEmailNotification(); return { success: isValidCode, }; async function sendEmailNotification() { const account = await db.account(sessionToken.uid); const geoData = request.app.geo; const ip = request.app.clientAddress; const service = request.payload.service || request.query.service; const emailOptions = { acceptLanguage: request.app.acceptLanguage, ip: ip, location: geoData.location, service: service, timeZone: geoData.timeZone, uaBrowser: request.app.ua.browser, uaBrowserVersion: request.app.ua.browserVersion, uaOS: request.app.ua.os, uaOSVersion: request.app.ua.osVersion, uaDeviceType: request.app.ua.deviceType, uid: sessionToken.uid, }; // Check to see if this token was just verified, if it is, then this means // the user has enabled two-step authentication, otherwise send new device // login email. if (isValidCode) { if (!tokenVerified) { return mailer.sendPostAddTwoStepAuthenticationEmail( account.emails, account, emailOptions ); } return mailer.sendNewDeviceLoginEmail( account.emails, account, emailOptions ); } } }, }, ]; };