packages/fxa-auth-server/lib/routes/utils/signin.js (427 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 emailUtils = require('./email'); const isA = require('joi'); const validators = require('../validators'); const butil = require('../../crypto/butil'); const error = require('../../error'); const { Container } = require('typedi'); const { AccountEventsManager } = require('../../account-events'); const { emailsMatch } = require('fxa-shared').email.helpers; const otp = require('../utils/otp'); const BASE_36 = validators.BASE_36; // An arbitrary, but very generous, limit on the number of active sessions. // Currently, only for metrics purposes, not enforced. const MAX_ACTIVE_SESSIONS = 200; module.exports = ( log, config, customs, db, mailer, cadReminders, glean, statsd ) => { const unblockCodeLifetime = (config.signinUnblock && config.signinUnblock.codeLifetime) || 0; const unblockCodeLen = (config.signinUnblock && config.signinUnblock.codeLength) || 8; const otpOptions = config.otp; const otpUtils = otp(log, config, db, statsd); const accountEventsManager = Container.has(AccountEventsManager) ? Container.get(AccountEventsManager) : { recordSecurityEvent: () => {}, }; return { validators: { UNBLOCK_CODE: isA .string() .regex(BASE_36) .length(unblockCodeLen) .optional(), }, /** * Check if the password a user entered matches the one on * file for the account. If it does not, flag the account with * customs. Higher level code will take care of * returning an error to the user. */ async checkPassword(accountRecord, password, clientAddress) { if (butil.buffersAreEqual(accountRecord.authSalt, butil.ONES)) { await customs.flag(clientAddress, { email: accountRecord.email, errno: error.ERRNO.ACCOUNT_RESET, }); throw error.mustResetAccount(accountRecord.email); } const verifyHash = await password.verifyHash(); const match = await db.checkPassword(accountRecord.uid, verifyHash); if (match.v2) { password.clientVersion = 2; return true; } if (match.v1) { password.clientVersion = 1; return true; } await customs.flag(clientAddress, { email: accountRecord.email, errno: error.ERRNO.INCORRECT_PASSWORD, }); return false; }, /** * Check if the user is logging in with the correct email address * for their account. */ checkEmailAddress(accountRecord, email, originalLoginEmail) { // The `originalLoginEmail` param, if specified, tells us the email address // that the user typed into the login form. This might differ from the address // used for calculating the password hash, which is provided in `email` param. if (!originalLoginEmail) { originalLoginEmail = email; } // Logging in with a secondary email address is not currently supported. if ( !emailsMatch( originalLoginEmail, accountRecord.primaryEmail.normalizedEmail ) ) { throw error.cannotLoginWithSecondaryEmail(); } return Promise.resolve(true); }, /** * Check if user is allowed a password-checking attempt, and if so then * load their accountRecord. These two operations are intertwined due * to the "unblock codes" feature, which allows users to bypass customs * checks and which requires us to load the account record for processing. * * Returns an object with the following information about the process: * * { * accountRecord: the user's account record loaded form the db * didSigninUnblock: whether an unblock code was successfully used * } */ async checkCustomsAndLoadAccount(request, email, checkAuthenticatedUid) { let accountRecord, originalError; let didSigninUnblock = false; try { try { // For testing purposes, some email addresses are forced // to go through signin unblock on every login attempt. const forced = config.signinUnblock && config.signinUnblock.forcedEmailAddresses; if (forced && forced.test(email)) { throw error.requestBlocked(true); } if (checkAuthenticatedUid) { await customs.checkAuthenticated( request, checkAuthenticatedUid, 'accountLogin' ); } else { await customs.check(request, email, 'accountLogin'); } } catch (e) { originalError = e; // Non-customs-related errors get thrown straight back to the caller. if ( e.errno !== error.ERRNO.REQUEST_BLOCKED && e.errno !== error.ERRNO.THROTTLED ) { throw e; } await request.emitMetricsEvent('account.login.blocked'); // If this customs error cannot be bypassed with email confirmation, // throw it straight back to the caller. const verificationMethod = e.output.payload.verificationMethod; if ( verificationMethod !== 'email-captcha' || !request.payload.unblockCode ) { throw e; } // Check for a valid unblockCode, to allow the request to proceed. // This requires that we load the accountRecord to learn the uid. const unblockCode = request.payload.unblockCode.toUpperCase(); accountRecord = await db.accountRecord(email); try { const code = await db.consumeUnblockCode( accountRecord.uid, unblockCode ); if (Date.now() - code.createdAt > unblockCodeLifetime) { log.info('Account.login.unblockCode.expired', { uid: accountRecord.uid, }); throw error.invalidUnblockCode(); } didSigninUnblock = true; await request.emitMetricsEvent( 'account.login.confirmedUnblockCode' ); } catch (e) { if (e.errno !== error.ERRNO.INVALID_UNBLOCK_CODE) { throw e; } await request.emitMetricsEvent('account.login.invalidUnblockCode'); throw e; } } // If we didn't load it above while checking unblock codes, // it's now safe to load the account record from the db. if (!accountRecord) { // If `originalLoginEmail` is specified, we need to fetch the account record tied // to that email. In the case where a user has changed their primary email, the `email` // value here is really the value used to hash the password and has no guarantee to // belong to the user. if (request.payload.originalLoginEmail) { accountRecord = await db.accountRecord( request.payload.originalLoginEmail ); } else { accountRecord = await db.accountRecord(email); } } return { accountRecord, didSigninUnblock }; } catch (e) { // Some errors need to be flagged with customs. if ( e.errno === error.ERRNO.INVALID_UNBLOCK_CODE || e.errno === error.ERRNO.ACCOUNT_UNKNOWN ) { customs.flag(request.app.clientAddress, { email: email, errno: e.errno, }); } // For any error other than INVALID_UNBLOCK_CODE, hide it behind the original customs error. // This prevents us from accidentally leaking additional info to a caller that's been // blocked, including e.g. whether or not the target account exists. if (originalError && e.errno !== error.ERRNO.INVALID_UNBLOCK_CODE) { throw originalError; } throw e; } }, /** * Send all the various notifications that result from a new signin. * This includes emailing the user, logging metrics events, and * notifying attached services. */ async sendSigninNotifications( request, accountRecord, sessionToken, verificationMethod ) { const service = request.payload.service || request.query.service; const redirectTo = request.payload.redirectTo; const resume = request.payload.resume; const isUnverifiedAccount = !accountRecord.primaryEmail.isVerified; let sessions; const { deviceId, flowId, flowBeginTime } = await request.app.metricsContext; const mustVerifySession = sessionToken.mustVerify && !sessionToken.tokenVerified; // The final event to complete the login flow depends on the details // of the flow being undertaken, so prepare accordingly. let flowCompleteSignal; if (service === 'sync') { // Sync signins are only complete when the browser actually syncs. flowCompleteSignal = 'account.signed'; } else if (mustVerifySession) { // Sessions that require verification are only complete once confirmed. flowCompleteSignal = 'account.confirmed'; } else { // Otherwise, the login itself is the end of the flow. flowCompleteSignal = 'account.login'; } request.setMetricsFlowCompleteSignal(flowCompleteSignal, 'login'); await stashMetricsContext(); await checkNumberOfActiveSessions(); await emitLoginEvent(); await sendEmail(); await recordSecurityEvent(); return; async function stashMetricsContext() { await request.stashMetricsContext(sessionToken); if (mustVerifySession) { // There is no session token when we emit account.confirmed // so stash the data against a synthesized "token" instead. return request.stashMetricsContext({ uid: accountRecord.uid, id: sessionToken.tokenVerificationId, }); } } async function checkNumberOfActiveSessions() { sessions = await db.sessions(accountRecord.uid); if (sessions.length > MAX_ACTIVE_SESSIONS) { // There's no spec-compliant way to error out // as a result of having too many active sessions. // For now, just log metrics about it. log.error('Account.login', { uid: accountRecord.uid, userAgent: request.headers['user-agent'], numSessions: sessions.length, }); } } async function emitLoginEvent() { await request.emitMetricsEvent('account.login', { uid: accountRecord.uid, }); if (request.payload.reason === 'signin') { const geoData = request.app.geo; const country = geoData.location && geoData.location.country; const countryCode = geoData.location && geoData.location.countryCode; await log.notifyAttachedServices('login', request, { country, countryCode, deviceCount: sessions.length, email: accountRecord.primaryEmail.email, service, uid: accountRecord.uid, userAgent: request.headers['user-agent'], }); } } async function sendEmail() { log.info('account.signin.sendEmail', { uid: accountRecord.uid, isUnverifiedAccount, mustVerifySession, }); // For unverified accounts, we always re-send the account verification email. if (isUnverifiedAccount) { return await sendVerifyAccountEmail(); } // If the session needs to be verified, send the sign-in confirmation email. if (mustVerifySession) { return await sendVerifySessionEmail(); } // Otherwise, no email is necessary. } async function sendVerifyAccountEmail() { if (verificationMethod === 'email-otp') { return sendVerifyLoginCodeEmail(); } // If the session doesn't require verification, // fall back to the account-level email code for the link. const emailCode = sessionToken.tokenVerificationId || accountRecord.primaryEmail.emailCode; try { await mailer.sendVerifyEmail([], accountRecord, { code: emailCode, service, redirectTo, resume, acceptLanguage: request.app.acceptLanguage, deviceId, flowId, flowBeginTime, timeZone: request.app.geo.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, }); await request.emitMetricsEvent('email.verification.sent'); } catch (err) { log.error('mailer.verification.error', { err, }); throw err; } } async function sendVerifySessionEmail() { // If this login requires a confirmation, check to see if a specific method was specified in // the request. If none was specified, use the `email` verificationMethod. switch (verificationMethod) { case 'email': // Sends an email containing a link to verify login return await sendVerifyLoginEmail(); case 'email-2fa': case 'email-otp': // Sends an email containing a code that can verify a login return await sendVerifyLoginCodeEmail(); case 'email-captcha': // `email-captcha` is a custom verification method used only for // unblock codes. We do not need to send a verification email // in this case. break; case 'totp-2fa': // This verification method requires a user to use a third-party // application. break; case 'sms-2fa': // This verification method requires a user to have a recovery phone // registered break; default: return await sendVerifyLoginEmail(); } } async function sendVerifyLoginEmail() { log.info('account.signin.confirm.start', { uid: accountRecord.uid, tokenVerificationId: sessionToken.tokenVerificationId, }); const geoData = request.app.geo; try { await mailer.sendVerifyLoginEmail( accountRecord.emails, accountRecord, { acceptLanguage: request.app.acceptLanguage, code: sessionToken.tokenVerificationId, deviceId, flowId, flowBeginTime, redirectTo: redirectTo, resume: resume, 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, } ); await request.emitMetricsEvent('email.confirmation.sent'); } catch (err) { log.error('mailer.confirmation.error', { err, }); throw emailUtils.sendError(err, isUnverifiedAccount); } } async function sendVerifyLoginCodeEmail() { log.info('account.token.code.start', { uid: accountRecord.uid, }); const secret = accountRecord.primaryEmail.emailCode; const code = otpUtils.generateOtpCode(secret, otpOptions); const { timeZone } = request.app.geo; try { await mailer.sendVerifyLoginCodeEmail( accountRecord.emails, accountRecord, { acceptLanguage: request.app.acceptLanguage, code, deviceId, flowId, flowBeginTime, redirectTo, resume, service, 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, } ); await request.emitMetricsEvent('email.tokencode.sent'); await glean.login.verifyCodeEmailSent(request, { uid: sessionToken.uid, }); } catch (err) { log.error('mailer.tokencode.error', { err }); throw err; } } function recordSecurityEvent() { accountEventsManager.recordSecurityEvent(db, { name: 'account.login', uid: accountRecord.uid, ipAddr: request.app.clientAddress, tokenId: sessionToken.id, additionalInfo: { userAgent: request.headers['user-agent'], location: request.app.geo.location, }, }); } }, async createKeyFetchToken(request, accountRecord, password, sessionToken) { const wrapWrapKb = password.clientVersion === 2 ? accountRecord.wrapWrapKbVersion2 : accountRecord.wrapWrapKb; const wrapKb = await password.unwrap(wrapWrapKb); const keyFetchToken = await db.createKeyFetchToken({ uid: accountRecord.uid, kA: accountRecord.kA, wrapKb: wrapKb, emailVerified: accountRecord.primaryEmail.isVerified, tokenVerificationId: sessionToken.tokenVerificationId, }); await request.stashMetricsContext(keyFetchToken); return keyFetchToken; }, getSessionVerificationStatus(sessionToken, verificationMethod) { if (!sessionToken.emailVerified) { // for unverified accounts, only 'email', and 'email-otp' are valid. // email-otp is the end goal, but a transition train is needed. // Set the default to 'email' to handle train->train upgrades. If // a user of content-server X loads their JS and then auth-server X+1 // is deployed, the content server of train X is not able to handle // the 'email-otp' result and still send the user to the /confirm screen // that expect verification links. if (verificationMethod !== 'email-otp') { verificationMethod = 'email'; } return { verified: false, verificationMethod: verificationMethod, verificationReason: 'signup', }; } if (sessionToken.mustVerify && !sessionToken.tokenVerified) { return { verified: false, // Override the verification method if it was explicitly specified in the request. verificationMethod: verificationMethod || 'email', verificationReason: 'login', }; } return { verified: true }; }, /** * Remove verification reminders for the account. */ async cleanupReminders(response, account) { // We should only really remove reminders if the session // was marked as verified, ie have met all the requirements // to start syncing if (response.verified) { await cadReminders.delete(account.uid); } return; }, }; };