packages/fxa-auth-server/lib/routes/account.ts (2,169 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 { Account, getAccountCustomerByUid } from 'fxa-shared/db/models/auth'; import { AppStoreSubscription, PlayStoreSubscription, } from 'fxa-shared/dto/auth/payments/iap-subscription'; import { WrappedErrorCodes } from 'fxa-shared/email/emailValidatorErrors'; import TopEmailDomains from 'fxa-shared/email/topEmailDomains'; import { tryResolveIpv4, tryResolveMx } from 'fxa-shared/email/validateEmail'; import ScopeSet from 'fxa-shared/oauth/scopes'; import { WebSubscription } from 'fxa-shared/subscriptions/types'; import isA from 'joi'; import Stripe from 'stripe'; import { Container } from 'typedi'; import * as uuid from 'uuid'; import { ConfigType } from '../../config'; import ACCOUNT_DOCS from '../../docs/swagger/account-api'; import MISC_DOCS from '../../docs/swagger/misc-api'; import DESCRIPTION from '../../docs/swagger/shared/descriptions'; import authMethods from '../authMethods'; import random from '../crypto/random'; import error from '../error'; import { getClientById } from '../oauth/client'; import { generateAccessToken } from '../oauth/grant'; import jwt from '../oauth/jwt'; import { CapabilityService } from '../payments/capability'; import { AppStoreSubscriptions } from '../payments/iap/apple-app-store/subscriptions'; import { PlaySubscriptions } from '../payments/iap/google-play/subscriptions'; import { appStoreSubscriptionPurchaseToAppStoreSubscriptionDTO, playStoreSubscriptionPurchaseToPlayStoreSubscriptionDTO, } from '../payments/iap/iap-formatter'; import { StripeHelper } from '../payments/stripe'; import { AuthLogger, AuthRequest } from '../types'; import { deleteAccountIfUnverified } from './utils/account'; import emailUtils from './utils/email'; import requestHelper from './utils/request_helper'; import validators from './validators'; import { AccountEventsManager } from '../account-events'; import { gleanMetrics } from '../metrics/glean'; import { AccountDeleteManager } from '../account-delete'; import { uuidTransformer } from 'fxa-shared/db/transformers'; import { DeleteAccountTasks, ReasonForDeletion } from '@fxa/shared/cloud-tasks'; import { ProfileClient } from '@fxa/profile/client'; import { DB } from '../db'; import { StatsD } from 'hot-shots'; import { recordSecurityEvent } from './utils/security-event'; const METRICS_CONTEXT_SCHEMA = require('../metrics/context').schema; const HEX_STRING = validators.HEX_STRING; const MS_ONE_HOUR = 1000 * 60 * 60; const MS_ONE_DAY = MS_ONE_HOUR * 24; const MS_ONE_WEEK = MS_ONE_DAY * 7; const MS_ONE_MONTH = MS_ONE_DAY * 30; export class AccountHandler { private OAUTH_DISABLE_NEW_CONNECTIONS_FOR_CLIENTS: Set<string>; private otpUtils: any; private otpOptions: ConfigType['otp']; private skipConfirmationForEmailAddresses: string[]; private capabilityService: CapabilityService; private accountEventsManager: AccountEventsManager; private accountDeleteManager: AccountDeleteManager; private accountTasks: DeleteAccountTasks; private profileClient: ProfileClient; constructor( private log: AuthLogger, private db: DB, private mailer: any, private Password: any, private config: ConfigType, private customs: any, private signinUtils: any, private signupUtils: any, private push: any, private verificationReminders: any, private subscriptionAccountReminders: any, private oauth: any, private stripeHelper: StripeHelper, private glean: ReturnType<typeof gleanMetrics>, private statsd: StatsD ) { this.otpUtils = require('./utils/otp')(log, config, db, statsd); this.skipConfirmationForEmailAddresses = config.signinConfirmation .skipForEmailAddresses as string[]; this.OAUTH_DISABLE_NEW_CONNECTIONS_FOR_CLIENTS = new Set( (config.oauth.disableNewConnectionsForClients as string[]) || [] ); this.otpOptions = config.otp; this.capabilityService = Container.get(CapabilityService); this.accountEventsManager = Container.get(AccountEventsManager); this.accountDeleteManager = Container.get(AccountDeleteManager); this.accountTasks = Container.get(DeleteAccountTasks); this.profileClient = Container.get(ProfileClient); } private async generateRandomValues() { const hex16 = await random.hex(16); const hex32 = await random.hex(32); return { hex16, hex32 }; } private async createPassword(authPW: any, authSalt: any) { const password = new this.Password( authPW, authSalt, this.config.verifierVersion ); const verifyHash = await password.verifyHash(); return { password, verifyHash }; } private async createAccount(options: { authPW: string; authPWVersion2?: string; wrapKb?: string; wrapKbVersion2?: string; clientSalt?: string; authSalt: string; email: string; emailCode: string; preVerified: boolean; request: AuthRequest; service?: string; userAgentString: string; }) { const { authPW, authPWVersion2, wrapKb, wrapKbVersion2, clientSalt, authSalt, email, emailCode, preVerified, request, service, userAgentString, } = options; const { password, verifyHash } = await this.createPassword( authPW, authSalt ); // Handle authPWVersion2 credentials let password2: any | undefined = undefined; let verifyHashVersion2 = undefined; let wrapWrapKb = await random.hex(32); let wrapWrapKbVersion2 = undefined; if (authPWVersion2) { password2 = new this.Password( authPWVersion2, authSalt, this.config.verifierVersion ); verifyHashVersion2 = await password2?.verifyHash(); wrapWrapKbVersion2 = await password2?.wrap(wrapKbVersion2); // When version 2 credentials are supplied, the wrapKb will also be supplied. // This is necessary to the same kB values are produced for both passwords. wrapWrapKb = await password.wrap(wrapKb); } const kA = await random.hex(32); const locale = request.app.acceptLanguage; if (!locale) { // We're seeing a surprising number of accounts created // without a proper locale. Log details to help debug this. this.log.info('account.create.emptyLocale', { email: email, locale: locale, agent: userAgentString, }); } const account = await this.db.createAccount({ uid: uuid.v4({}, Buffer.alloc(16)).toString('hex'), createdAt: Date.now(), email: email, emailCode: emailCode, emailVerified: preVerified, kA, wrapWrapKb, wrapWrapKbVersion2, accountResetToken: null, passwordForgotToken: null, authSalt: authSalt, clientSalt: clientSalt, verifierVersion: password.version, verifyHash: verifyHash, verifyHashVersion2: verifyHashVersion2, verifierSetAt: Date.now(), locale, }); await request.emitMetricsEvent('account.created', { uid: account.uid, }); this.glean.registration.accountCreated(request, { uid: account.uid, }); const geoData = request.app.geo; const country = geoData.location && geoData.location.country; const countryCode = geoData.location && geoData.location.countryCode; if (account.emailVerified) { await this.log.notifyAttachedServices('verified', request, { email: account.email, locale: account.locale, service, uid: account.uid, userAgent: userAgentString, country, countryCode, }); } await this.log.notifyAttachedServices('login', request, { deviceCount: 1, country, countryCode, email: account.email, service, uid: account.uid, userAgent: userAgentString, }); await this.profileClient.deleteCache(account.uid); await this.log.notifyAttachedServices('profileDataChange', request, { uid: account.uid, }); return { password, password2, account }; } private setMetricsFlowCompleteSignal(request: AuthRequest, service?: string) { let flowCompleteSignal; // 'account.signed' is only used for 'sync' // use the default for browser sign-ins that are not sync (e.g., service=relay) if (service === 'sync') { flowCompleteSignal = 'account.signed'; } else { flowCompleteSignal = 'account.verified'; } request.setMetricsFlowCompleteSignal(flowCompleteSignal, 'registration'); } private async createSessionToken(options: { account: any; request: AuthRequest; tokenVerificationId: any; }) { const { request, account, tokenVerificationId } = options; const { browser: uaBrowser, browserVersion: uaBrowserVersion, os: uaOS, osVersion: uaOSVersion, deviceType: uaDeviceType, formFactor: uaFormFactor, } = request.app.ua; const sessionToken = await this.db.createSessionToken({ uid: account.uid, email: account.email, emailCode: account.emailCode, emailVerified: account.emailVerified, verifierSetAt: account.verifierSetAt, mustVerify: requestHelper.wantsKeys(request), tokenVerificationId: tokenVerificationId, uaBrowser, uaBrowserVersion, uaOS, uaOSVersion, uaDeviceType, uaFormFactor, }); await request.stashMetricsContext(sessionToken); await request.stashMetricsContext({ uid: account.uid, id: account.emailCode, }); return sessionToken; } private async sendVerifyCode(options: { account: any; request: AuthRequest; sessionToken: any; tokenVerificationId: any; verificationMethod: string; }) { const { request, account, sessionToken, tokenVerificationId, verificationMethod, } = options; const { deviceId, flowId, flowBeginTime, productId, planId } = await request.app.metricsContext; const locale = request.app.acceptLanguage; const form = request.payload as any; const query = request.query; const ip = request.app.clientAddress; const style = form.style; if (account.emailVerified) { return; } try { switch (verificationMethod) { case 'email-otp': { const secret = account.emailCode; const code = this.otpUtils.generateOtpCode(secret, this.otpOptions); await this.mailer.sendVerifyShortCodeEmail([], account, { acceptLanguage: locale, code, deviceId, flowId, flowBeginTime, productId, planId, ip, location: request.app.geo.location, timeZone: request.app.geo.timeZone, uaBrowser: sessionToken.uaBrowser, uaBrowserVersion: sessionToken.uaBrowserVersion, uaOS: sessionToken.uaOS, uaOSVersion: sessionToken.uaOSVersion, uaDeviceType: sessionToken.uaDeviceType, uid: sessionToken.uid, }); break; } default: { await this.mailer.sendVerifyEmail([], account, { code: account.emailCode, service: form.service || query.service, redirectTo: form.redirectTo, resume: form.resume, acceptLanguage: locale, deviceId, flowId, flowBeginTime, productId, planId, ip, location: request.app.geo.location, timeZone: request.app.geo.timeZone, style, uaBrowser: sessionToken.uaBrowser, uaBrowserVersion: sessionToken.uaBrowserVersion, uaOS: sessionToken.uaOS, uaOSVersion: sessionToken.uaOSVersion, uaDeviceType: sessionToken.uaDeviceType, uid: sessionToken.uid, }); } } if (tokenVerificationId) { // Log server-side metrics for confirming verification rates this.log.info('account.create.confirm.start', { uid: account.uid, tokenVerificationId, }); this.glean.registration.confirmationEmailSent(request, { uid: account.uid, }); } await this.verificationReminders.create( account.uid, flowId, flowBeginTime ); } catch (err) { this.log.error('mailer.sendVerifyCode.1', { err }); if (tokenVerificationId) { // Log possible email bounce, used for confirming verification rates this.log.error('account.create.confirm.error', { uid: account.uid, err, tokenVerificationId, }); } // show an error to the user, the account is already created. // the user can come back later and try again. throw emailUtils.sendError(err, true); } } private async createKeyFetchToken(options: { account: any; password: any; request: AuthRequest; tokenVerificationId: any; v2?: boolean; }) { const { request, account, password, tokenVerificationId, v2 } = options; if (requestHelper.wantsKeys(request)) { const wrapKb = await password.unwrap( v2 ? account.wrapWrapKbVersion2 : account.wrapWrapKb ); const keyFetchToken = await this.db.createKeyFetchToken({ uid: account.uid, kA: account.kA, wrapKb, emailVerified: account.emailVerified, tokenVerificationId, }); await request.stashMetricsContext(keyFetchToken); return keyFetchToken; } return undefined; } private accountCreateResponse(options: { account: any; keyFetchToken: any; keyFetchTokenVersion2: any; sessionToken: any; verificationMethod: any; }) { const { account, sessionToken, keyFetchToken, keyFetchTokenVersion2, verificationMethod, } = options; const response: Record<string, any> = { uid: account.uid, sessionToken: sessionToken.data, authAt: sessionToken.lastAuthAt(), }; if (keyFetchToken) { response.keyFetchToken = keyFetchToken.data; } if (keyFetchTokenVersion2) { response.keyFetchTokenVersion2 = keyFetchTokenVersion2.data; } if (verificationMethod) { response.verificationMethod = verificationMethod; } return response; } private async checkEmailDomainValidity(email: string): Promise<boolean> { let invalidDomain = false; const domain = email.split('@')[1]; if (!TopEmailDomains.has(domain)) { let mxCheck = false, ipv4Check = false; try { mxCheck = await tryResolveMx(domain); ipv4Check = await tryResolveIpv4(domain); } catch (error) { if (WrappedErrorCodes.includes(error.code)) { this.log.error(`DNS query error: ${error.code}`, error); } else { this.log.error('checkEmailDomainValidity', error); } // if there are any errors we ignore this domain check mxCheck = true; ipv4Check = true; } invalidDomain = !mxCheck && !ipv4Check; } return invalidDomain; } async accountCreate(request: AuthRequest) { this.log.begin('Account.create', request); const form = request.payload as any; const query = request.query; const email = form.email; const authPW = form.authPW; const wrapKb = form.wrapKb; const authPWVersion2 = form.authPWVersion2; const wrapKbVersion2 = form.wrapKbVersion2; const clientSalt = form.clientSalt; const userAgentString = request.headers['user-agent']; const service = form.service || query.service; const preVerified = !!form.preVerified; const verificationMethod = form.verificationMethod; request.validateMetricsContext(); if (this.OAUTH_DISABLE_NEW_CONNECTIONS_FOR_CLIENTS.has(service)) { throw error.disabledClientId(service); } await this.customs.check(request, email, 'accountCreate'); await deleteAccountIfUnverified( this.db, this.stripeHelper, this.log, request, email ); const { hex16: emailCode, hex32: authSalt } = await this.generateRandomValues(); // Verified sessions should only be created for preverified accounts. const tokenVerificationId = preVerified ? undefined : emailCode; this.setMetricsFlowCompleteSignal(request, service); const { account, password, password2 } = await this.createAccount({ authPW, authPWVersion2, wrapKb, wrapKbVersion2, clientSalt, authSalt, email, emailCode, preVerified, request, service, userAgentString, }); const sessionToken = await this.createSessionToken({ account, request, tokenVerificationId, }); await this.sendVerifyCode({ account, request, sessionToken, tokenVerificationId, verificationMethod, }); const keyFetchToken = await this.createKeyFetchToken({ account, password, request, tokenVerificationId, }); let keyFetchTokenVersion2; if (password2) { keyFetchTokenVersion2 = await this.createKeyFetchToken({ account, password: password2, request, tokenVerificationId, v2: true, }); } recordSecurityEvent('account.create', { db: this.db, request, account, }); return this.accountCreateResponse({ account, keyFetchToken, keyFetchTokenVersion2, sessionToken, verificationMethod, }); } async accountStub(request: AuthRequest) { this.log.begin('Account.stub', request); const { email, clientId, wantsSetupToken } = request.payload as any; await this.customs.check(request, email, 'accountCreate'); if (this.OAUTH_DISABLE_NEW_CONNECTIONS_FOR_CLIENTS.has(clientId)) { throw error.disabledClientId(clientId); } const invalidDomain = await this.checkEmailDomainValidity(email); if (invalidDomain) { throw error.accountCreationRejected(); } const client = await getClientById(clientId); await deleteAccountIfUnverified( this.db, this.stripeHelper, this.log, request, email ); const { hex16: emailCode, hex32: authSalt } = await this.generateRandomValues(); const [kA, wrapWrapKb] = await random.hex(32, 32); const account = await this.db.createAccount({ uid: uuid.v4({}, Buffer.alloc(16)).toString('hex'), createdAt: Date.now(), email, emailCode, emailVerified: false, kA, wrapWrapKb, wrapWrapKbVersion2: null, authSalt, verifierVersion: this.config.verifierVersion, verifyHash: Buffer.alloc(32).toString('hex'), verifyHashVersion2: null, verifierSetAt: 0, locale: request.app.acceptLanguage, clientSalt: null, }); const access = await generateAccessToken({ clientId: client.id, name: client.name, canGrant: client.canGrant, publicClient: client.publicClient, userId: uuidTransformer.to(account.uid), scope: ScopeSet.fromString(`profile ${client.allowedScopes}`), ttl: 1800, }); this.setMetricsFlowCompleteSignal(request, clientId); await request.stashMetricsContext({ uid: account.uid, id: account.uid, }); // This token is designed to work with the account/finish_setup endpoint // and setPasswordOnStubAccount method. In some scenarios, like invocations // from subscriptions, the setup token is generated by the calling code, // and provided to the user in an email. const setupToken = wantsSetupToken ? jwt.sign( { uid: account.uid, iat: Date.now(), }, { header: { typ: 'fin+JWT' } } ) : undefined; return { uid: account.uid, access_token: access.token.toString('hex'), setup_token: setupToken, }; } async setPasswordOnStubAccount({ account, authPW, authPWVersion2, wrapKb, wrapKbVersion2, clientSalt, }: { account: Account; authPW: string; authPWVersion2: string; wrapKb: string; wrapKbVersion2: string; clientSalt: string; }) { // Only set a password on an unverified stub account. if (account.verifierSetAt !== 0) { throw error.unauthorized('token already used'); } const { authSalt, uid, wrapWrapKb } = account; const { password, verifyHash } = await this.createPassword( authPW, authSalt ); const v2Data = await (async () => { if (authPWVersion2) { const password2 = new this.Password( authPWVersion2, authSalt, this.config.verifierVersion, 2 ); const verifyHashVersion2 = await password2.verifyHash(); // Important! In the case of V2, the client will determine the // the kB value and provide wrapKb & wrapKbVersion2 to us. We // do this to ensure that wrapWrapKb and wrapWrapKbVersion2 result // in the same kB value. Something only the client would be able // to do since kB is private. const wrapWrapKb = await password.wrap(wrapKb); const wrapWrapKbVersion2 = await password2.wrap(wrapKbVersion2); return { clientSalt, wrapWrapKb, verifyHashVersion2, wrapWrapKbVersion2, }; } return {}; })(); const data = { authSalt, verifyHash, wrapWrapKb, verifierVersion: password.version, keysHaveChanged: true, ...v2Data, }; await this.db.resetAccount({ uid }, data); } async finishSetup(request: AuthRequest) { this.log.begin('Account.finishSetup', request); const form = request.payload as any; const { authPW, authPWVersion2, wrapKb, wrapKbVersion2, clientSalt, token, } = form; let uid; try { const payload = (await jwt.verify(token, { typ: 'fin+JWT', ignoreExpiration: true, })) as any; uid = payload.uid; form.uid = payload.uid; const account = await this.db.account(uid); await this.setPasswordOnStubAccount({ account, authPW, authPWVersion2, wrapKb, wrapKbVersion2, clientSalt, }); await this.signupUtils.verifyAccount(request, account, {}); const sessionToken = await this.createSessionToken({ account, request, // this route counts as verification tokenVerificationId: undefined, }); await this.subscriptionAccountReminders.delete(uid); return { uid, sessionToken: sessionToken.data, verified: sessionToken.emailVerified, }; } catch (err) { this.log.error('Account.finish_setup.error', { err, }); // if it errored out after verifiying the account // remove the uid from the list of accounts to send reminders to. if (uid) { const account = await this.db.account(uid); if (account.verifierSetAt > 0) { await this.subscriptionAccountReminders.delete(uid); } } throw err; } } async setPassword(request: AuthRequest) { this.log.begin('Account.set_password', request); const form = request.payload as any; const { authPW, authPWVersion2, wrapKb, wrapKbVersion2, clientSalt, metricsContext, } = form; const { query } = request; const auth = request.auth; const { user: uid } = auth.credentials; const account = await this.db.account(uid as string); const email = account.primaryEmail?.email; await this.customs.check(request, email, 'setPassword'); const response: Record<string, any> = {}; response.uid = uid; try { await this.setPasswordOnStubAccount({ account, authPW, authPWVersion2, wrapKb, wrapKbVersion2, clientSalt, }); const { emailCode: tokenVerificationId } = account; const sessionToken = await this.createSessionToken({ account, request, tokenVerificationId, }); response.sessionToken = sessionToken.data; if (query.sendVerifyEmail) { await this.sendVerifyCode({ account, request, sessionToken, tokenVerificationId, verificationMethod: 'email-otp', }); } // This is a brand new (unverified) user who just created their first // subscription, so we know we will only have one priceId result here. const priceId = ( await this.capabilityService.subscribedPriceIds(uid as string) )[0]; const price = (await this.stripeHelper.allPlans()).find( (p) => p.id === priceId ); // Cached prices have products expanded already const product = price?.product as Stripe.Product; if (product && product?.id && product?.name) { await this.subscriptionAccountReminders.create( uid, metricsContext.flowId, metricsContext.flowBeginTime, metricsContext.deviceId, product.id, product.name ); } // TODO (FXA-5557): record flow metrics return response; } catch (err) { this.log.error('Account.set_password.error', { err, }); throw err; } } async login(request: AuthRequest) { this.log.begin('Account.login', request); const form = request.payload as any; const email = form.email; const authPW = form.authPW; const originalLoginEmail = form.originalLoginEmail; let verificationMethod = form.verificationMethod; const service = form.service || request.query.service; const requestNow = Date.now(); let accountRecord: any, password: any, passwordChangeRequired: any, sessionToken: any, keyFetchToken: any, keyFetchTokenVersion2: any, didSigninUnblock: any; let securityEventRecency = Infinity, securityEventVerified = false; request.validateMetricsContext(); if (this.OAUTH_DISABLE_NEW_CONNECTIONS_FOR_CLIENTS.has(service)) { throw error.disabledClientId(service); } const checkCustomsAndLoadAccount = async () => { const res = await this.signinUtils.checkCustomsAndLoadAccount( request, email ); accountRecord = res.accountRecord; if (accountRecord.disabledAt) { throw error.cannotLoginWithEmail(); } // Remember whether they did a signin-unblock, // because we can use it to bypass token verification. didSigninUnblock = res.didSigninUnblock; }; const checkEmailAndPassword = async () => { // Third party accounts might not have set a password and // won't be able to login via email/password. if (accountRecord.verifierSetAt <= 0) { throw error.cannotLoginNoPasswordSet(); } await this.signinUtils.checkEmailAddress( accountRecord, email, originalLoginEmail ); password = new this.Password( authPW, accountRecord.authSalt, accountRecord.verifierVersion ); const match = await this.signinUtils.checkPassword( accountRecord, password, request.app.clientAddress ); if (!match) { recordSecurityEvent('account.login.failure', { db: this.db, request, account: accountRecord, }); throw error.incorrectPassword(accountRecord.email, email); } }; const checkSecurityHistory = async () => { try { const events = await this.db.verifiedLoginSecurityEvents({ uid: accountRecord.uid, ipAddr: request.app.clientAddress, }); if (events.length > 0) { let latest = 0; events.forEach((ev: any) => { if (ev.verified) { securityEventVerified = true; if (ev.createdAt > latest) { latest = ev.createdAt; } } }); if (securityEventVerified) { securityEventRecency = requestNow - latest; let coarseRecency; if (securityEventRecency < MS_ONE_DAY) { coarseRecency = 'day'; } else if (securityEventRecency < MS_ONE_WEEK) { coarseRecency = 'week'; } else if (securityEventRecency < MS_ONE_MONTH) { coarseRecency = 'month'; } else { coarseRecency = 'old'; } this.log.info('Account.history.verified', { uid: accountRecord.uid, events: events.length, recency: coarseRecency, }); } else { this.log.info('Account.history.unverified', { uid: accountRecord.uid, events: events.length, }); } } } catch (err) { // Security event history allows some convenience during login, // but errors here shouldn't fail the entire request. // so errors shouldn't stop the login attempt this.log.error('Account.history.error', { err: err, uid: accountRecord.uid, }); } }; const checkTotpToken = async () => { // Check to see if the user has a TOTP token and it is verified and // enabled, if so then the verification method is automatically forced so that // they have to verify the token. const hasTotpToken = await this.otpUtils.hasTotpToken(accountRecord); if (hasTotpToken) { // User has enabled TOTP, no way around it, they must verify TOTP token verificationMethod = 'totp-2fa'; } else if (!hasTotpToken && verificationMethod === 'totp-2fa') { // Error if requesting TOTP verification with TOTP not setup throw error.totpRequired(); } }; const forceTokenVerification = (request: AuthRequest, account: any) => { // If there was anything suspicious about the request, // we should force token verification. if (request.app.isSuspiciousRequest) { return 'suspect'; } if (this.config.signinConfirmation?.forceGlobally) { return 'global'; } // If it's an email address used for testing etc, // we should force token verification. if ( this.config.signinConfirmation?.forcedEmailAddresses?.test( account.primaryEmail.email ) ) { return 'email'; } return false; }; const skipTokenVerification = (request: AuthRequest, account: any) => { // If they're logging in from an IP address on which they recently did // another, successfully-verified login, then we can consider this one // verified as well without going through the loop again. // Convict type introspection fails to properly identify the number here // so we have to cast it to a number. const allowedRecency = (this.config.securityHistory.ipProfiling .allowedRecency as unknown as number) || 0; if (securityEventVerified && securityEventRecency < allowedRecency) { this.log.info('Account.ipprofiling.seenAddress', { uid: account.uid, }); return true; } // If the account was recently created, don't make the user // confirm sign-in for a configurable amount of time. This will reduce // the friction of a user adding a second device. const skipForNewAccounts = this.config.signinConfirmation.skipForNewAccounts; if (skipForNewAccounts?.enabled) { const accountAge = requestNow - account.createdAt; if (accountAge <= (skipForNewAccounts.maxAge as unknown as number)) { this.log.info('account.signin.confirm.bypass.age', { uid: account.uid, }); return true; } } // Certain accounts have the ability to *always* skip sign-in confirmation // regardless of account age or device. This is for internal use where we need // to guarantee the login experience. const lowerCaseEmail = account.primaryEmail.normalizedEmail.toLowerCase(); const alwaysSkip = this.skipConfirmationForEmailAddresses?.includes(lowerCaseEmail); if (alwaysSkip) { this.log.info('account.signin.confirm.bypass.always', { uid: account.uid, }); return true; } return false; }; const forcePasswordChange = (account: any) => { // If it's an email address used for testing etc, // we should force password change. if ( this.config.forcePasswordChange?.forcedEmailAddresses?.test( account.primaryEmail.email ) ) { return true; } // otw only force if account lockAt flag set return accountRecord.lockedAt > 0; }; const createSessionToken = async () => { // All sessions are considered unverified by default. let needsVerificationId = true; // However! To help simplify the login flow, we can use some heuristics to // decide whether to consider the session pre-verified. Some accounts // get excluded from this process, e.g. testing accounts where we want // to know for sure what flow they're going to see. const verificationForced = forceTokenVerification(request, accountRecord); if (!verificationForced) { if (skipTokenVerification(request, accountRecord)) { needsVerificationId = false; } } // If they just went through the signin-unblock flow, they have already verified their email. // We don't need to force them to do that again, just make a verified session. if (didSigninUnblock) { needsVerificationId = false; } // If the request wants keys , user *must* confirm their login session before they can actually // use it. Otherwise, they don't *have* to verify their session. All sessions are created // unverified because it prevents them from being used for sync. let mustVerifySession = needsVerificationId && (verificationForced === 'suspect' || verificationForced === 'global' || requestHelper.wantsKeys(request)); // For accounts with TOTP, we always force verifying a session. if (verificationMethod === 'totp-2fa') { mustVerifySession = true; needsVerificationId = true; } if (forcePasswordChange(accountRecord)) { passwordChangeRequired = true; needsVerificationId = true; mustVerifySession = true; // Users that are forced to change their passwords, **MUST** // also confirm they have access to the inbox and do // a confirmation loop. verificationMethod = verificationMethod || 'email-otp'; } const [tokenVerificationId] = needsVerificationId ? [await random.hex(16)] : []; const { browser: uaBrowser, browserVersion: uaBrowserVersion, os: uaOS, osVersion: uaOSVersion, deviceType: uaDeviceType, formFactor: uaFormFactor, } = request.app.ua; const sessionTokenOptions = { uid: accountRecord.uid, email: accountRecord.primaryEmail.email, emailCode: accountRecord.primaryEmail.emailCode, emailVerified: accountRecord.primaryEmail.isVerified, verifierSetAt: accountRecord.verifierSetAt, mustVerify: mustVerifySession, tokenVerificationId: tokenVerificationId, uaBrowser, uaBrowserVersion, uaOS, uaOSVersion, uaDeviceType, uaFormFactor, }; sessionToken = await this.db.createSessionToken(sessionTokenOptions); }; const sendSigninNotifications = async () => { await this.signinUtils.sendSigninNotifications( request, accountRecord, sessionToken, verificationMethod ); // For new logins that don't send some other sort of email, // send an after-the-fact notification email so that the user // is aware that something happened on their account. if (accountRecord.primaryEmail.isVerified) { if (sessionToken.tokenVerified || !sessionToken.mustVerify) { const geoData = request.app.geo; const service = (request.payload as any).service || request.query.service; const ip = request.app.clientAddress; const { deviceId, flowId, flowBeginTime } = await request.app.metricsContext; try { await this.mailer.sendNewDeviceLoginEmail( accountRecord.emails, accountRecord, { acceptLanguage: request.app.acceptLanguage, deviceId, flowId, flowBeginTime, ip, location: geoData.location, 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, } ); } catch (err) { // If we couldn't email them, no big deal. Log // and pretend everything worked. this.log.trace( 'Account.login.sendNewDeviceLoginNotification.error', { error: err, } ); } } } }; const createKeyFetchToken = async () => { if (requestHelper.wantsKeys(request)) { if (password.clientVersion === 2) { keyFetchTokenVersion2 = await this.signinUtils.createKeyFetchToken( request, accountRecord, password, sessionToken ); } else { keyFetchToken = await this.signinUtils.createKeyFetchToken( request, accountRecord, password, sessionToken ); } } }; const createResponse = async () => { const response: Record<string, any> = { uid: sessionToken.uid, sessionToken: sessionToken.data, authAt: sessionToken.lastAuthAt(), metricsEnabled: !accountRecord.metricsOptOutAt, }; if (keyFetchToken) { response.keyFetchToken = keyFetchToken.data; } if (keyFetchTokenVersion2) { response.keyFetchTokenVersion2 = keyFetchTokenVersion2.data; } if (passwordChangeRequired) { response.verified = false; response.verificationReason = 'change_password'; response.verificationMethod = verificationMethod; } else { Object.assign( response, this.signinUtils.getSessionVerificationStatus( sessionToken, verificationMethod ) ); } await this.signinUtils.cleanupReminders(response, accountRecord); if (response.verified) { this.glean.login.success(request, { uid: sessionToken.uid }); } return response; }; await checkCustomsAndLoadAccount(); await checkEmailAndPassword(); await checkSecurityHistory(); await checkTotpToken(); await createSessionToken(); await sendSigninNotifications(); await createKeyFetchToken(); return await createResponse(); } async status(request: AuthRequest) { const sessionToken = request.auth.credentials; if (sessionToken) { return { exists: true, locale: sessionToken.locale }; } else if (request.query.uid) { try { const uid = request.query.uid; await this.db.account(uid); return { exists: true }; } catch (err) { if (err.errno === error.ERRNO.ACCOUNT_UNKNOWN) { return { exists: false }; } throw err; } } else { throw error.missingRequestParameter('uid'); } } async accountStatusCheck(request: AuthRequest) { const email = (request.payload as any).email; const checkDomain = !!(request.payload as any).checkDomain; const thirdPartyAuthStatus = !!(request.payload as any) .thirdPartyAuthStatus; let invalidDomain = false; if (checkDomain) { invalidDomain = await this.checkEmailDomainValidity(email); } await this.customs.check(request, email, 'accountStatusCheck'); const result: { exists: boolean; invalidDomain?: boolean; hasLinkedAccount?: boolean; hasPassword?: boolean; } = { exists: false, invalidDomain: undefined, hasLinkedAccount: undefined, hasPassword: undefined, }; try { if (thirdPartyAuthStatus) { const account = await this.db.accountRecord(email, { linkedAccounts: true, }); // account must exist or unknown account error is thrown result.exists = true; result.hasLinkedAccount = (account.linkedAccounts?.length || 0) > 0; result.hasPassword = account.verifierSetAt > 0; } else { const exist = await this.db.accountExists(email); result.exists = exist; } if (checkDomain) { result.invalidDomain = invalidDomain; } return result; } catch (err) { if (err.errno === error.ERRNO.ACCOUNT_UNKNOWN) { result.exists = false; if (checkDomain) { result.invalidDomain = invalidDomain; } return result; } throw err; } } async profile(request: AuthRequest) { const auth = request.auth; let uid, scope; if (auth.strategy === 'sessionToken') { uid = auth.credentials.uid; scope = { contains: () => true }; } else { uid = auth.credentials.user; scope = ScopeSet.fromArray(auth.credentials.scope || []); } const res: Record<string, any> = {}; const account = await this.db.account(uid as string); if (scope.contains('profile:email')) { res.email = account.primaryEmail?.email; } if (scope.contains('profile:locale') && account.locale) { res.locale = account.locale; } if (scope.contains('profile:amr')) { const amrValues = await authMethods.availableAuthenticationMethods( this.db, account ); res.authenticationMethods = Array.from(amrValues); res.authenticatorAssuranceLevel = authMethods.maximumAssuranceLevel(amrValues); } if (scope.contains('profile:account_disabled_at')) { res.accountDisabledAt = account.disabledAt; } if (scope.contains('profile:account_locked_at')) { res.accountLockedAt = account.lockedAt; } if (scope.contains('profile:keys_changed_at')) { res.keysChangedAt = account.keysChangedAt; } if ( this.config.subscriptions?.enabled && scope.contains('profile:subscriptions') ) { const capabilities = await this.capabilityService.subscriptionCapabilities(uid as string); if (Object.keys(capabilities).length > 0) { res.subscriptionsByClientId = capabilities; } } // If no keys set on the response, there was no valid profile scope found. We only // want to return `profileChangedAt` if a valid scope was found and set. if (Object.keys(res).length !== 0) { res.profileChangedAt = account.profileChangedAt; res.metricsEnabled = !account.metricsOptOutAt; } return res; } async keys(request: AuthRequest) { this.log.begin('Account.keys', request); const keyFetchToken = request.auth.credentials as unknown as { id: string; uid: string; tokenVerified: boolean; emailVerified: boolean; keyBundle: any; }; const verified = keyFetchToken.tokenVerified && keyFetchToken.emailVerified; if (!verified) { // don't delete the token on use until the account is verified throw error.unverifiedAccount(); } await this.db.deleteKeyFetchToken(keyFetchToken); await request.emitMetricsEvent('account.keyfetch', { uid: keyFetchToken.uid, }); return { bundle: keyFetchToken.keyBundle, }; } async reset(request: AuthRequest) { this.log.begin('Account.reset', request); const accountResetToken = request.auth.credentials as any; const { authPW, authPWVersion2, clientSalt, sessionToken: hasSessionToken, recoveryKeyId, wrapKbVersion2, isFirefoxMobileClient, } = request.payload as any; let wrapKb = (request.payload as any).wrapKb; let account: any, sessionToken: any, keyFetchToken: any, keyFetchTokenVersion2: any, verifyHash: any, verifyHashVersion2: any, wrapWrapKb: any, wrapWrapKbVersion2: any, password: any, password2: any, hasTotpToken = false, tokenVerificationId: any; const checkRecoveryKey = () => { if (recoveryKeyId) { return this.db.getRecoveryKey(accountResetToken.uid, recoveryKeyId); } return Promise.resolve(); }; const checkTotpToken = async () => { hasTotpToken = await this.otpUtils.hasTotpToken({ uid: accountResetToken.uid, }); }; const resetAccountData = async () => { // Users using a valid recovery key don't need to have a 2FA verified accountResetToken // since recovery key in this case is considered a 2FA method. if (hasTotpToken && !recoveryKeyId) { if ( accountResetToken.verificationMethod === undefined || accountResetToken.verificationMethod <= 1 ) { throw error.unverifiedSession(); } } const authSalt = await random.hex(32); let keysHaveChanged; password = new this.Password( authPW, authSalt, this.config.verifierVersion ); verifyHash = await password.verifyHash(); if (authPWVersion2) { password2 = new this.Password( authPWVersion2, authSalt, this.config.verifierVersion, 2 ); verifyHashVersion2 = await password2.verifyHash(); } if (recoveryKeyId) { // We have the previous kB, just re-wrap it with the new password. if (authPWVersion2) { wrapWrapKbVersion2 = await password2.wrap(wrapKbVersion2); } wrapWrapKb = await password.wrap(wrapKb); keysHaveChanged = false; } else { if (authPWVersion2) { // For v2 credentials, the client will supply a new wrapKbs. This is to ensure // that both wrapKb and wrapKbVersion2 can derive the same kB. It is up to the client // to ensure this! wrapWrapKb = await password.wrap(wrapKb); wrapWrapKbVersion2 = await password2.wrap(wrapKbVersion2); keysHaveChanged = true; } else { // We need to regenerate kB and wrap it with the new password. wrapWrapKb = await random.hex(32); wrapKb = await password.unwrap(wrapWrapKb); keysHaveChanged = true; } } // db.resetAccount() deletes all the devices saved in the account, // so grab the list to notify before we call it. const devicesToNotify = await request.app.devices; // Reset the account, and delete any other outstanding account-related tokens. await this.db.resetAccount(accountResetToken, { authSalt, clientSalt, verifyHash, verifyHashVersion2, wrapWrapKb, wrapWrapKbVersion2, verifierVersion: password.version, keysHaveChanged, }); // Notify various interested parties about this password reset. // These can all safely happen in parallel. account = await this.db.account(accountResetToken.uid); await Promise.all([ this.push.notifyPasswordReset(account.uid, devicesToNotify), request.emitMetricsEvent('account.reset', { uid: account.uid, }), (() => { if (verifyHashVersion2) { return request.emitMetricsEvent('account.reset.credentials.v2', { uid: account.uid, }); } else { return request.emitMetricsEvent('account.reset.credentials.v1', { uid: account.uid, }); } })(), this.glean.resetPassword.accountReset(request, { uid: account.uid }), this.glean.resetPassword.createNewSuccess(request, { uid: account.uid, }), recoveryKeyId ? this.glean.resetPassword.recoveryKeyCreatePasswordSuccess(request, { uid: account.uid, }) : Promise.resolve(), this.log.notifyAttachedServices('reset', request, { uid: account.uid, generation: account.verifierSetAt, }), (async () => { await this.profileClient.deleteCache(account.uid); await this.log.notifyAttachedServices('profileDataChange', request, { uid: account.uid, }); })(), this.oauth.removeTokensAndCodes(account.uid), this.customs.reset(request, account.email), ]); }; const recoveryKeyDeleteAndEmailNotification = async () => { // If the password was reset with an account recovery key, then we explicitly delete the // account recovery key and send an email that the account was reset with it. if (recoveryKeyId) { await this.db.deleteRecoveryKey(account.uid); const geoData = request.app.geo; const ip = request.app.clientAddress; const emailOptions = { acceptLanguage: request.app.acceptLanguage, ip: ip, location: geoData.location, 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: account.uid, }; // a new key won't be autogenerated for users with 2FA enabled or currently, // for mobile users, due to the web view automatically closing after a // successful login. The `isFirefoxMobileClient` option matches the // client-side check against `integration.isFirefoxMobileClient()`. if (hasTotpToken || isFirefoxMobileClient) { return await this.mailer.sendPasswordResetWithRecoveryKeyPromptEmail( account.emails, account, emailOptions ); } else { return await this.mailer.sendPasswordResetAccountRecoveryEmail( account.emails, account, emailOptions ); } } }; const createSessionToken = async () => { if (hasSessionToken) { const { browser: uaBrowser, browserVersion: uaBrowserVersion, os: uaOS, osVersion: uaOSVersion, deviceType: uaDeviceType, formFactor: uaFormFactor, } = request.app.ua; // Since the only way to reach this point is clicking a // link from the user's email and verifying TOTP if they have it, // we create a verified session token. tokenVerificationId = null; const sessionTokenOptions = { uid: account.uid, email: account.primaryEmail.email, emailCode: account.primaryEmail.emailCode, emailVerified: account.primaryEmail.isVerified, verifierSetAt: account.verifierSetAt, mustVerify: false, tokenVerificationId, uaBrowser, uaBrowserVersion, uaOS, uaOSVersion, uaDeviceType, uaFormFactor, }; sessionToken = await this.db.createSessionToken(sessionTokenOptions); return await request.propagateMetricsContext( accountResetToken, sessionToken ); } }; const createKeyFetchToken = async () => { if (requestHelper.wantsKeys(request)) { if (!hasSessionToken) { // Sanity-check: any client requesting keys, // should also be requesting a sessionToken. throw error.missingRequestParameter('sessionToken'); } keyFetchToken = await this.db.createKeyFetchToken({ uid: account.uid, kA: account.kA, wrapKb: wrapKb, emailVerified: account.primaryEmail.isVerified, tokenVerificationId, }); if (authPWVersion2) { keyFetchTokenVersion2 = await this.db.createKeyFetchToken({ uid: account.uid, kA: account.kA, wrapKb: wrapKbVersion2, emailVerified: account.primaryEmail.isVerified, tokenVerificationId, }); } return await request.propagateMetricsContext( accountResetToken, keyFetchToken ); } }; const createResponse = () => { // If no sessionToken, this could be a legacy client // attempting to reset an account password, return legacy response. if (!hasSessionToken) { return {}; } const response: Record<string, any> = { uid: sessionToken.uid, sessionToken: sessionToken.data, verified: sessionToken.emailVerified, authAt: sessionToken.lastAuthAt(), }; if (requestHelper.wantsKeys(request)) { if (keyFetchToken) { response.keyFetchToken = keyFetchToken.data; } if (keyFetchTokenVersion2) { response.keyFetchTokenVersion2 = keyFetchToken.data2; } } Object.assign( response, this.signinUtils.getSessionVerificationStatus(sessionToken, undefined) ); return response; }; await checkRecoveryKey(); await checkTotpToken(); await resetAccountData(); await recoveryKeyDeleteAndEmailNotification(); await createSessionToken(); await createKeyFetchToken(); recordSecurityEvent('account.reset', { db: this.db, account, request, }); return createResponse(); } async getCredentialsStatus(request: AuthRequest) { this.log.begin('Account.getCredentialsStatus', request); const email = (request.payload as any).email; await this.customs.check(request, email, 'getCredentialsStatus'); const accountRecord = await this.db.accountRecord(email); if (accountRecord.disabledAt) { throw error.cannotLoginWithEmail(); } if (accountRecord.verifierSetAt <= 0) { throw error.cannotLoginWithEmail(); } const response = { currentVersion: accountRecord.clientSalt ? 'v2' : 'v1', clientSalt: accountRecord.clientSalt ? accountRecord.clientSalt : undefined, upgradeNeeded: !accountRecord.wrapWrapKbVersion2, }; return response; } async destroy(request: AuthRequest) { this.log.begin('Account.destroy', request); const { authPW, email: emailAddress } = request.payload as any; await this.customs.check(request, emailAddress, 'accountDestroy'); let accountRecord: Account; try { accountRecord = await this.db.accountRecord(emailAddress); } catch (err) { if (err.errno === error.ERRNO.ACCOUNT_UNKNOWN) { await this.customs.flag(request.app.clientAddress, { email: emailAddress, errno: err.errno, }); } throw err; } const sessionToken = request.auth && request.auth.credentials; const hasTotpToken = await this.otpUtils.hasTotpToken(accountRecord); // Someone tried to delete an account with TOTP but did not specify a session. // This shouldn't happen in practice, but just in case we throw unverified session. if (!sessionToken && hasTotpToken) { throw error.unverifiedSession(); } // If TOTP is enabled, ensure that the session has the correct assurance level before // deleting account. if ( sessionToken && hasTotpToken && (sessionToken.tokenVerificationId || (sessionToken.authenticatorAssuranceLevel as number) <= 1) ) { throw error.unverifiedSession(); } // We can also check that the email was verified. This proves the account is active and // related to a valid email. Accounts that aren't activated get deleted automatically // by cron jobs anyways... if ( this.config.accountDestroy.requireVerifiedAccount && !accountRecord.emailVerified ) { throw error.unverifiedAccount(); } // Regardless of whether or not an account has TOTP, we must make sure the user actually // owns the account. This means the sessionToken must be verified. // // The UI will request an OTP code verification before destroying the account in the event // the session is currently unverified. The following check will ensure that this OTP code // was actually provided by the user. if ( this.config.accountDestroy.requireVerifiedSession && !sessionToken.tokenVerified ) { throw error.unverifiedSession(); } // In other scenarios, fall back to the default behavior and let the user // delete the account. If they have a password set, we verify it here. Users // that don't have a password set will be able to delete their account without // this step. if (accountRecord.verifierSetAt > 0) { const password = new this.Password( authPW, accountRecord.authSalt, accountRecord.verifierVersion ); const isMatchingPassword = await this.signinUtils.checkPassword( accountRecord, password, request.app.clientAddress ); if (!isMatchingPassword) { throw error.incorrectPassword(accountRecord.email, emailAddress); } } await this.accountDeleteManager.quickDelete( accountRecord.uid, ReasonForDeletion.UserRequested ); // data eng rely on this to delete the account data from BQ this.log.info('accountDeleted.ByRequest', { uid: accountRecord.uid }); const result = await getAccountCustomerByUid(accountRecord.uid); await this.accountTasks.deleteAccount({ uid: accountRecord.uid, customerId: result?.stripeCustomerId, reason: ReasonForDeletion.UserRequested, }); this.glean.account.deleteComplete(request, { uid: accountRecord.uid }); return {}; } async getAccount(request: AuthRequest) { this.log.begin('Account.get', request); const { uid } = request.auth.credentials; let webSubscriptions: Awaited<WebSubscription[]> = []; let iapGooglePlaySubscriptions: Awaited<PlayStoreSubscription[]> = []; let iapAppStoreSubscriptions: Awaited<AppStoreSubscription[]> = []; if (this.config.subscriptions?.enabled && this.stripeHelper) { try { const customer = await this.stripeHelper.fetchCustomer(uid as string, [ 'subscriptions', ]); if (customer && customer.subscriptions) { webSubscriptions = await this.stripeHelper.subscriptionsToResponse( customer.subscriptions ); } if (this.config.subscriptions?.playApiServiceAccount?.enabled) { const playSubscriptions = Container.get(PlaySubscriptions); iapGooglePlaySubscriptions = ( await playSubscriptions.getSubscriptions(uid as string) ).map(playStoreSubscriptionPurchaseToPlayStoreSubscriptionDTO); } if (this.config.subscriptions?.appStore?.enabled) { const appStoreSubscriptions = Container.get(AppStoreSubscriptions); iapAppStoreSubscriptions = ( await appStoreSubscriptions.getSubscriptions(uid as string) ).map(appStoreSubscriptionPurchaseToAppStoreSubscriptionDTO); } } catch (err) { if (err.errno !== error.ERRNO.UNKNOWN_SUBSCRIPTION_CUSTOMER) { throw err; } } } return { subscriptions: [ ...iapGooglePlaySubscriptions, ...iapAppStoreSubscriptions, ...webSubscriptions, ], }; } } export const accountRoutes = ( log: AuthLogger, db: any, mailer: any, Password: any, config: ConfigType, customs: any, signinUtils: any, signupUtils: any, push: any, verificationReminders: any, subscriptionAccountReminders: any, oauth: any, stripeHelper: StripeHelper, pushbox: any, glean: ReturnType<typeof gleanMetrics>, statsd: any ) => { const accountHandler = new AccountHandler( log, db, mailer, Password, config, customs, signinUtils, signupUtils, push, verificationReminders, subscriptionAccountReminders, oauth, stripeHelper, glean, statsd ); const routes = [ { method: 'POST', path: '/account/create', options: { ...ACCOUNT_DOCS.ACCOUNT_CREATE_POST, validate: { query: isA.object({ keys: isA.boolean().optional().description(DESCRIPTION.keys), service: validators.service.description(DESCRIPTION.service), }), payload: isA .object({ email: validators .email() .required() .description(DESCRIPTION.email), authPW: validators.authPW.description(DESCRIPTION.authPW), authPWVersion2: validators.authPWVersion2 .optional() .description(DESCRIPTION.authPWVersion2), wrapKb: validators.wrapKb .optional() .description(DESCRIPTION.wrapKb), wrapKbVersion2: validators.wrapKb .optional() .description(DESCRIPTION.wrapKbVersion2), clientSalt: validators.clientSalt .optional() .description(DESCRIPTION.clientSalt), service: validators.service.description(DESCRIPTION.service), redirectTo: validators .redirectTo(config.smtp.redirectDomain) .optional() .description(DESCRIPTION.redirectTo), resume: isA .string() .max(2048) .optional() .description(DESCRIPTION.resume), metricsContext: METRICS_CONTEXT_SCHEMA, style: isA.string().allow('trailhead').optional(), verificationMethod: validators.verificationMethod .optional() .description(DESCRIPTION.verificationMethod), // preVerified is not available in production mode. ...(!(config as any).isProduction && { preVerified: isA.boolean(), }), }) .and('authPWVersion2', 'wrapKb', 'wrapKbVersion2', 'clientSalt'), }, response: { schema: isA.object({ uid: isA.string().regex(HEX_STRING).required(), sessionToken: isA.string().regex(HEX_STRING).required(), keyFetchToken: isA.string().regex(HEX_STRING).optional(), keyFetchTokenVersion2: isA.string().regex(HEX_STRING).optional(), authAt: isA.number().integer().description(DESCRIPTION.authAt), verificationMethod: validators.verificationMethod .optional() .description(DESCRIPTION.verificationMethod), }), }, }, handler: (request: AuthRequest) => accountHandler.accountCreate(request), }, { method: 'POST', path: '/account/stub', options: { ...ACCOUNT_DOCS.ACCOUNT_STUB_POST, validate: { payload: isA.object({ email: validators.email().required(), clientId: validators.clientId.required(), metricsContext: METRICS_CONTEXT_SCHEMA, wantsSetupToken: isA.boolean().optional(), }), }, }, handler: (request: AuthRequest) => accountHandler.accountStub(request), }, { method: 'POST', path: '/account/finish_setup', options: { ...ACCOUNT_DOCS.ACCOUNT_FINISH_SETUP_POST, validate: { payload: isA .object({ token: validators.jwt, authPW: validators.authPW.description(DESCRIPTION.authPW), wrapKb: validators.wrapKb .optional() .description(DESCRIPTION.wrapKb), authPWVersion2: validators.authPWVersion2 .optional() .description(DESCRIPTION.authPWVersion2), wrapKbVersion2: validators.wrapKb .optional() .description(DESCRIPTION.wrapKbVersion2), clientSalt: validators.clientSalt .optional() .description(DESCRIPTION.clientSalt), }) .and('authPWVersion2', 'wrapKbVersion2', 'clientSalt'), }, }, handler: (request: AuthRequest) => accountHandler.finishSetup(request), }, { method: 'POST', path: '/account/set_password', options: { ...ACCOUNT_DOCS.ACCOUNT_SET_PASSWORD_POST, auth: { mode: 'required', payload: false, strategy: 'oauthToken', }, validate: { query: isA.object({ sendVerifyEmail: isA .boolean() .optional() .default(true) .description(DESCRIPTION.sendVerifyEmail), }), payload: isA .object({ authPW: validators.authPW.description(DESCRIPTION.authPW), authPWVersion2: validators.authPW .optional() .description(DESCRIPTION.authPWVersion2), wrapKb: validators.wrapKb .optional() .description(DESCRIPTION.wrapKb), wrapKbVersion2: validators.wrapKb .optional() .description(DESCRIPTION.wrapKbVersion2), clientSalt: validators.clientSalt .optional() .description(DESCRIPTION.clientSalt), metricsContext: METRICS_CONTEXT_SCHEMA, service: validators.service.description(DESCRIPTION.service), }) .and('authPWVersion2', 'wrapKb', 'wrapKbVersion2', 'clientSalt'), }, response: { schema: isA.object({ sessionToken: isA .string() .regex(HEX_STRING) .required() .description(DESCRIPTION.sessionToken), uid: isA .string() .regex(HEX_STRING) .required() .description(DESCRIPTION.uid), }), }, }, handler: (request: AuthRequest) => accountHandler.setPassword(request), }, { method: 'POST', path: '/account/login', apidoc: { errors: [ error.unknownAccount, error.requestBlocked, error.incorrectPassword, error.cannotLoginWithSecondaryEmail, error.invalidUnblockCode, error.cannotLoginWithEmail, error.cannotSendEmail, ], }, options: { ...ACCOUNT_DOCS.ACCOUNT_LOGIN_POST, validate: { query: isA.object({ keys: isA.boolean().optional().description(DESCRIPTION.keys), service: validators.service.description(DESCRIPTION.service), verificationMethod: validators.verificationMethod .optional() .description(DESCRIPTION.verificationMethod), }), payload: isA.object({ email: validators.email().required().description(DESCRIPTION.email), authPW: validators.authPW.description(DESCRIPTION.authPW), service: validators.service.description(DESCRIPTION.service), redirectTo: validators .redirectTo(config.smtp.redirectDomain) .optional(), resume: isA.string().optional().description(DESCRIPTION.resume), reason: isA .string() .max(16) .optional() .description(DESCRIPTION.reason), unblockCode: signinUtils.validators.UNBLOCK_CODE.description( DESCRIPTION.unblockCode ), metricsContext: METRICS_CONTEXT_SCHEMA, originalLoginEmail: validators .email() .optional() .description(DESCRIPTION.originalLoginEmail), verificationMethod: validators.verificationMethod .optional() .description(DESCRIPTION.verificationMethod), }), }, response: { schema: isA.object({ uid: isA.string().regex(HEX_STRING).required(), sessionToken: isA.string().regex(HEX_STRING).required(), keyFetchToken: isA.string().regex(HEX_STRING).optional(), keyFetchTokenVersion2: isA.string().regex(HEX_STRING).optional(), verificationMethod: isA .string() .optional() .description(DESCRIPTION.verificationMethod), verificationReason: isA .string() .optional() .description(DESCRIPTION.verificationReason), verified: isA.boolean().required(), authAt: isA.number().integer().description(DESCRIPTION.authAt), metricsEnabled: isA.boolean().required(), }), }, }, handler: (request: AuthRequest) => accountHandler.login(request), }, { method: 'GET', path: '/account/status', options: { ...ACCOUNT_DOCS.ACCOUNT_STATUS_GET, auth: { mode: 'optional', strategy: 'sessionToken', }, validate: { query: { uid: isA.string().min(32).max(32).regex(HEX_STRING), }, }, }, handler: (request: AuthRequest) => accountHandler.status(request), }, { method: 'POST', path: '/account/status', options: { ...ACCOUNT_DOCS.ACCOUNT_STATUS_POST, validate: { payload: isA.object({ email: validators.email().required(), thirdPartyAuthStatus: isA.boolean().optional().default(false), checkDomain: isA.optional(), }), }, response: { schema: isA.object({ exists: isA.boolean().required(), hasLinkedAccount: isA.boolean().optional(), hasPassword: isA.boolean().optional(), invalidDomain: isA.boolean().optional(), }), }, }, handler: (request: AuthRequest) => accountHandler.accountStatusCheck(request), }, { method: 'GET', path: '/account/profile', options: { ...ACCOUNT_DOCS.ACCOUNT_PROFILE_GET, auth: { strategies: ['sessionToken', 'oauthToken'], }, response: { schema: isA.object({ email: isA.string().optional(), locale: isA.string().optional().allow(null), authenticationMethods: isA .array() .items(isA.string().required()) .optional(), authenticatorAssuranceLevel: isA.number().min(0), subscriptionsByClientId: isA.object().unknown(true).optional(), profileChangedAt: isA.number().min(0), metricsEnabled: isA.boolean().optional(), accountLockedAt: isA.number().optional().allow(null), accountDisabledAt: isA.number().optional().allow(null), keysChangedAt: isA.number().optional().allow(null), }), }, }, handler: (request: AuthRequest) => accountHandler.profile(request), }, { method: 'GET', path: '/account/keys', options: { ...ACCOUNT_DOCS.ACCOUNT_KEYS_GET, auth: { strategy: 'keyFetchTokenWithVerificationStatus', }, response: { schema: isA.object({ bundle: isA .string() .regex(HEX_STRING) .description(DESCRIPTION.bundle), }), }, }, handler: (request: AuthRequest) => accountHandler.keys(request), }, { method: 'POST', path: '/account/unlock/resend_code', options: { ...ACCOUNT_DOCS.ACCOUNT_UNLOCK_RESEND_CODE_POST, validate: { payload: true, }, }, handler: async function (request: AuthRequest) { log.error('Account.UnlockCodeResend', { request: request }); throw error.gone(); }, }, { method: 'POST', path: '/account/unlock/verify_code', options: { ...ACCOUNT_DOCS.ACCOUNT_UNLOCK_VERIFY_CODE_POST, validate: { payload: true, }, }, handler: async function (request: AuthRequest) { log.error('Account.UnlockCodeVerify', { request: request }); throw error.gone(); }, }, { method: 'POST', path: '/account/reset', options: { ...ACCOUNT_DOCS.ACCOUNT_RESET_POST, auth: { strategy: 'accountResetToken', payload: 'required', }, validate: { query: isA.object({ keys: isA.boolean().optional().description(DESCRIPTION.queryKeys), }), payload: isA .object({ authPW: validators.authPW.description(DESCRIPTION.authPW), authPWVersion2: validators.authPW .optional() .description(DESCRIPTION.authPW), wrapKb: validators.wrapKb .optional() .description(DESCRIPTION.wrapKb), wrapKbVersion2: validators.wrapKb .optional() .description(DESCRIPTION.wrapKbVersion2), clientSalt: validators.clientSalt .optional() .description(DESCRIPTION.clientSalt), recoveryKeyId: validators.recoveryKeyId .optional() .description(DESCRIPTION.recoveryKeyId), sessionToken: isA .boolean() .optional() .description(DESCRIPTION.sessionToken), isFirefoxMobileClient: isA .boolean() .optional() .description(DESCRIPTION.isFirefoxMobileClient), }) .custom((value, helper) => { if (value.authPWVersion2 && !value.wrapKb) { return helper.error('any.invalid'); } if (value.authPWVersion2 && !value.wrapKbVersion2) { return helper.error('any.invalid'); } if (value.authPWVersion2 && !value.clientSalt) { return helper.error('any.invalid'); } if ( !value.authPWVersion2 && value.recoveryKeyId && !value.wrapKb ) { return helper.error('any.invalid'); } if ( !value.authPWVersion2 && value.wrapKb && !value.recoveryKeyId ) { return helper.error('any.invalid'); } return value; }), }, }, handler: async (request: AuthRequest) => accountHandler.reset(request), }, { method: 'POST', path: '/account/destroy', options: { ...ACCOUNT_DOCS.ACCOUNT_DESTROY_POST, auth: { strategy: 'sessionToken', payload: 'required', }, validate: { payload: isA.object({ email: validators.email().required().description(DESCRIPTION.email), authPW: validators.authPW.description(DESCRIPTION.authPW), }), }, }, handler: (request: AuthRequest) => accountHandler.destroy(request), }, { method: 'POST', path: '/account/credentials/status', options: { ...ACCOUNT_DOCS.ACCOUNT_CREDENTIALS_STATUS, validate: { payload: isA.object({ email: validators.email().description(DESCRIPTION.email), }), }, response: { schema: isA.object({ currentVersion: isA.string().allow('v1', 'v2'), clientSalt: validators.clientSalt.optional(), upgradeNeeded: isA.boolean(), }), }, }, handler: (request: AuthRequest) => accountHandler.getCredentialsStatus(request), }, { method: 'GET', path: '/account', options: { ...MISC_DOCS.ACCOUNT_GET, auth: { strategy: 'sessionToken', }, response: { schema: isA.object({ // This endpoint is evolving, it's not just for subscriptions. // Ultimately we want it to become a one-stop shop for all of // the account data needed by the settings screen, so that we // can drastically reduce how many requests are made to the // backend. Discussion in: // // https://github.com/mozilla/fxa/issues/1808 subscriptions: isA .array() .items( validators.subscriptionsSubscriptionValidator, validators.subscriptionsGooglePlaySubscriptionValidator, validators.subscriptionsAppStoreSubscriptionValidator ), }), }, }, handler: (request: AuthRequest) => accountHandler.getAccount(request), }, ]; if (!(config as any).isProduction) { // programmatic account lockout was only available in // non-production mode. routes.push({ method: 'POST', path: '/account/lock', options: { ...MISC_DOCS.ACCOUNT_LOCK_POST, validate: { payload: true, }, } as any, handler: async function (request) { log.error('Account.lock', { request: request }); throw error.gone(); }, }); } return routes; };