packages/fxa-auth-server/lib/routes/session.js (551 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 error = require('../error'); const isA = require('joi'); const requestHelper = require('../routes/utils/request_helper'); const METRICS_CONTEXT_SCHEMA = require('../metrics/context').schema; const validators = require('./validators'); const Localizer = require('../l10n').default; const NodeRendererBindings = require('../senders/renderer/bindings-node').default; const SESSION_DOCS = require('../../docs/swagger/session-api').default; const DESCRIPTION = require('../../docs/swagger/shared/descriptions').default; const HEX_STRING = validators.HEX_STRING; const { recordSecurityEvent } = require('./utils/security-event'); module.exports = function ( log, db, Password, config, signinUtils, signupUtils, mailer, push, customs, glean, statsd ) { const otpUtils = require('../../lib/routes/utils/otp')( log, config, db, statsd ); const OAUTH_DISABLE_NEW_CONNECTIONS_FOR_CLIENTS = new Set( config.oauth.disableNewConnectionsForClients || [] ); const otpOptions = config.otp; const routes = [ { method: 'POST', path: '/session/destroy', options: { ...SESSION_DOCS.SESSION_DESTROY_POST, auth: { strategy: 'sessionToken', // since payload is allowed to be empty we do not // do hawk payload validation otherwise we may break existing clients }, validate: { payload: isA .object({ customSessionToken: isA .string() .min(64) .max(64) .regex(HEX_STRING) .optional() .description(DESCRIPTION.customSessionToken), }) .allow(null), }, }, handler: async function (request) { log.begin('Session.destroy', request); let sessionToken = request.auth.credentials; const { uid } = sessionToken; if (request.payload && request.payload.customSessionToken) { const customSessionToken = request.payload.customSessionToken; const tokenData = await db.sessionToken(customSessionToken); // NOTE: validate that the token belongs to the same user if (tokenData && uid === tokenData.uid) { sessionToken = { id: customSessionToken, uid, }; } else { throw error.invalidToken('Invalid session token'); } } await db.deleteSessionToken(sessionToken); await recordSecurityEvent('session.destroy', { db, request, }); return {}; }, }, { method: 'POST', path: '/session/reauth', apidoc: { errors: [ error.unknownAccount, error.requestBlocked, error.incorrectPassword, error.cannotLoginWithSecondaryEmail, error.invalidUnblockCode, error.cannotLoginWithEmail, ], }, options: { ...SESSION_DOCS.SESSION_REAUTH_POST, auth: { strategy: 'sessionToken', payload: 'required', }, validate: { query: isA.object({ keys: isA.boolean().optional(), service: validators.service, verificationMethod: validators.verificationMethod.optional(), }), payload: isA.object({ email: validators.email().required(), authPW: validators.authPW, service: validators.service, redirectTo: validators .redirectTo(config.smtp.redirectDomain) .optional(), resume: isA.string().optional(), reason: isA.string().max(16).optional(), unblockCode: signinUtils.validators.UNBLOCK_CODE, metricsContext: METRICS_CONTEXT_SCHEMA, originalLoginEmail: validators.email().optional(), verificationMethod: validators.verificationMethod.optional(), }), }, response: { schema: isA.object({ uid: isA.string().regex(HEX_STRING).required(), keyFetchToken: isA.string().regex(HEX_STRING).optional(), verificationMethod: isA.string().optional(), verificationReason: isA.string().optional(), verified: isA.boolean().required(), authAt: isA.number().integer(), metricsEnabled: isA.boolean().required(), }), }, }, handler: async function (request) { log.begin('Session.reauth', request); const sessionToken = request.auth.credentials; const { authPW, email, originalLoginEmail } = request.payload; const service = request.payload.service || request.query.service; let { verificationMethod } = request.payload; request.validateMetricsContext(); if (OAUTH_DISABLE_NEW_CONNECTIONS_FOR_CLIENTS.has(service)) { throw error.disabledClientId(service); } const { accountRecord } = await signinUtils.checkCustomsAndLoadAccount( request, email, sessionToken.uid ); await signinUtils.checkEmailAddress( accountRecord, email, originalLoginEmail ); const password = new Password( authPW, accountRecord.authSalt, accountRecord.verifierVersion ); const match = await signinUtils.checkPassword( accountRecord, password, request.app.clientAddress ); if (!match) { throw error.incorrectPassword(accountRecord.email, email); } // 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 otpUtils.hasTotpToken(accountRecord); if (hasTotpToken) { // User has enabled TOTP, no way around it, they must verify TOTP token verificationMethod = 'totp-2fa'; } else if (verificationMethod === 'totp-2fa') { // Error if requesting TOTP verification with TOTP not setup throw error.totpRequired(); } sessionToken.authAt = sessionToken.lastAccessTime = Date.now(); const { ua } = request.app; sessionToken.setUserAgentInfo({ uaBrowser: ua.browser, uaBrowserVersion: ua.browserVersion, uaOS: ua.os, uaOSVersion: ua.osVersion, uaDeviceType: ua.deviceType, uaFormFactor: ua.formFactor, }); if ( !sessionToken.mustVerify && (requestHelper.wantsKeys(request) || verificationMethod) ) { sessionToken.mustVerify = true; } await db.updateSessionToken(sessionToken); await signinUtils.sendSigninNotifications( request, accountRecord, sessionToken, verificationMethod ); const response = { uid: sessionToken.uid, authAt: sessionToken.lastAuthAt(), metricsEnabled: !accountRecord.metricsOptOut, }; if (requestHelper.wantsKeys(request)) { const keyFetchToken = await signinUtils.createKeyFetchToken( request, accountRecord, password, sessionToken ); response.keyFetchToken = keyFetchToken.data; } Object.assign( response, signinUtils.getSessionVerificationStatus( sessionToken, verificationMethod ) ); return response; }, }, { method: 'GET', path: '/session/status', options: { ...SESSION_DOCS.SESSION_STATUS_GET, auth: { strategy: 'sessionToken', }, response: { schema: isA.object({ state: isA.string().required(), uid: isA.string().regex(HEX_STRING).required(), }), }, }, handler: async function (request) { log.begin('Session.status', request); const sessionToken = request.auth.credentials; return { state: sessionToken.state, uid: sessionToken.uid, }; }, }, { method: 'POST', path: '/session/duplicate', options: { ...SESSION_DOCS.SESSION_DUPLICATE_POST, auth: { strategy: 'sessionToken', payload: 'required', }, validate: { payload: isA.object({ reason: isA.string().max(16).optional(), }), }, }, handler: async function (request) { log.begin('Session.duplicate', request); const origSessionToken = request.auth.credentials; const newTokenState = await origSessionToken.copyTokenState(); // Update UA info based on the requesting device. const { ua } = request.app; const newUAInfo = { uaBrowser: ua.browser, uaBrowserVersion: ua.browserVersion, uaOS: ua.os, uaOSVersion: ua.osVersion, uaDeviceType: ua.deviceType, uaFormFactor: ua.formFactor, }; // Copy all other details from the original sessionToken. // We have to lie a little here and copy the creation time // of the original sessionToken. If we set createdAt to the // current time, we would falsely report the new session's // `lastAuthAt` value as the current timestamp. const sessionTokenOptions = { ...newTokenState, ...newUAInfo, }; const newSessionToken = await db.createSessionToken(sessionTokenOptions); const response = { uid: newSessionToken.uid, sessionToken: newSessionToken.data, authAt: newSessionToken.lastAuthAt(), }; if (!newSessionToken.emailVerified) { response.verified = false; response.verificationMethod = 'email'; response.verificationReason = 'signup'; } else if (!newSessionToken.tokenVerified) { response.verified = false; response.verificationMethod = 'email'; response.verificationReason = 'login'; } else { response.verified = true; } return response; }, }, { method: 'POST', path: '/session/verify_code', options: { ...SESSION_DOCS.SESSION_VERIFY_CODE_POST, auth: { strategy: 'sessionToken', payload: 'required', }, validate: { payload: isA.object({ code: validators.DIGITS, service: validators.service, scopes: validators.scopes, // The `marketingOptIn` is safe to remove after train-167+ marketingOptIn: isA.boolean().optional(), newsletters: validators.newsletters, metricsContext: METRICS_CONTEXT_SCHEMA, }), }, }, handler: async function (request) { log.begin('Session.verify_code', request); const options = request.payload; const sessionToken = request.auth.credentials; const { code } = options; const { uid, email } = sessionToken; const devices = await request.app.devices; await customs.check(request, email, 'verifySessionCode'); request.emitMetricsEvent('session.verify_code'); // Check to see if the otp code passed matches the expected value from // using the account's' `emailCode` as the secret in the otp code generation. const account = await db.account(uid); const secret = account.primaryEmail.emailCode; const isValidCode = otpUtils.verifyOtpCode( code, secret, otpOptions, 'session.verify_code' ); if (!isValidCode) { throw error.invalidOrExpiredOtpCode(); } // If a valid code was sent, this verifies the session using the `email-2fa` method. // The assurance level will be ["pwd", "email"] or level 1. // **Note** the order of operations, to avoid any race conditions with push // notifications, we perform all DB operations first. await db.verifyTokensWithMethod(sessionToken.id, 'email-2fa'); // We have a matching code! Let's verify the account, session and send the // corresponding email and emit metrics. if (!account.primaryEmail.isVerified) { await signupUtils.verifyAccount(request, account, options); } else { request.emitMetricsEvent('account.confirmed', { uid }); glean.login.verifyCodeConfirmed(request, { uid }); await signinUtils.cleanupReminders({ verified: true }, account); await push.notifyAccountUpdated(uid, devices, 'accountConfirm'); } return {}; }, }, { method: 'POST', path: '/session/resend_code', options: { ...SESSION_DOCS.SESSION_RESEND_CODE_POST, auth: { strategy: 'sessionToken', }, }, handler: async function (request) { log.begin('Session.resend_code', request); const sessionToken = request.auth.credentials; request.emitMetricsEvent('session.resend_code'); const metricsContext = await request.gatherMetricsContext({}); // Check to see if this account has a verified TOTP token. If so, then it should // not be allowed to bypass TOTP requirement by sending a sign-in confirmation email. try { const result = await db.totpToken(sessionToken.uid); if (result && result.verified && result.enabled) { return {}; } } catch (err) { if (err.errno !== error.ERRNO.TOTP_TOKEN_NOT_FOUND) { throw err; } } // Generate the current otp code for the account based on the account's // `emailCode` as the secret. const account = await db.account(sessionToken.uid); const secret = account.primaryEmail.emailCode; await customs.check( request, account.primaryEmail.normalizedEmail, 'sendVerifyCode' ); const code = otpUtils.generateOtpCode(secret, otpOptions); const options = { acceptLanguage: account.locale || request.app.locale, code, timeZone: request.app.geo.timeZone, uaBrowser: sessionToken.uaBrowser, uaBrowserVersion: sessionToken.uaBrowserVersion, uaOS: sessionToken.uaOS, uaOSVersion: sessionToken.uaOSVersion, uaDeviceType: sessionToken.uaDeviceType, uid: sessionToken.uid, flowId: metricsContext.flow_id, flowBeginTime: metricsContext.flowBeginTime, deviceId: metricsContext.device_id, }; if (account.primaryEmail.isVerified) { // Unverified emails mean that the user is attempting to resend the code from signup page, // therefore they get sent a different email template with the code. await mailer.sendVerifyLoginCodeEmail( account.emails, account, options ); } else { await mailer.sendVerifyShortCodeEmail([], account, options); } return {}; }, }, { method: 'POST', path: '/session/verify/send_push', options: { ...SESSION_DOCS.SESSION_SEND_PUSH_POST, auth: { strategy: 'sessionToken', }, }, handler: async function (request) { log.begin('Session.send_push', request); const sessionToken = request.auth.credentials; const { uid, email, tokenVerificationId } = sessionToken; // Check to see if this account has a verified TOTP token. If so, then it should // not be allowed to bypass TOTP requirement by sending a sign-in push notification. try { const result = await db.totpToken(sessionToken.uid); if (result && result.verified && result.enabled) { return {}; } } catch (err) { if (err.errno !== error.ERRNO.TOTP_TOKEN_NOT_FOUND) { throw err; } } const allDevices = await db.devices(uid); const account = await db.account(sessionToken.uid); const secret = account.primaryEmail.emailCode; const code = otpUtils.generateOtpCode(secret, otpOptions); // Filter devices that can accept the push notification. const filteredDevices = allDevices.filter((d) => { // Don't push to the current device if (d.sessionTokenId === sessionToken.id) { return false; } // Exclude expired devices if (d.pushEndpointExpired === true) { return false; } // Currently, we only support sending push notifications to Firefox Desktop return d.type === 'desktop' && d.uaBrowser === 'Firefox'; }); const confirmUrl = `${config.contentServer.url}/signin_push_code_confirm`; const localizer = new Localizer(new NodeRendererBindings()); // If/when we use .localizeStrings in other files, probably move where strings are // maintained to separate file? const titleFtlId = 'session-verify-send-push-title-2'; const bodyFtlId = 'session-verify-send-push-body-2'; const ftlIdMsgs = [ { id: titleFtlId, message: 'Logging in to your Mozilla account?', }, { id: bodyFtlId, message: 'Click here to confirm it’s you', }, ]; const localizedStrings = await localizer.localizeStrings( request.app.locale, ftlIdMsgs ); const options = { title: localizedStrings[titleFtlId], body: localizedStrings[bodyFtlId], }; const { region, city, country } = request.app.geo; const remoteMetaData = { deviceName: sessionToken.deviceName, deviceFamily: sessionToken.uaBrowser, deviceOS: sessionToken.uaOS, ipAddress: request.app.clientAddress, city, region, country, }; const params = new URLSearchParams({ tokenVerificationId, code, uid, email, remoteMetaData: encodeURIComponent(JSON.stringify(remoteMetaData)), }); const url = `${confirmUrl}?${params.toString()}`; try { await push.notifyVerifyLoginRequest(uid, filteredDevices, { ...options, url, }); } catch (err) { log.error('Session.send_push', { uid: uid, error: err, }); } return {}; }, }, { method: 'POST', path: '/session/verify/verify_push', options: { ...SESSION_DOCS.SESSION_VERIFY_CODE_POST, auth: { strategy: 'sessionToken', }, validate: { payload: isA.object({ code: validators.DIGITS, tokenVerificationId: validators.hexString.length(32), }), }, }, handler: async function (request) { log.begin('Session.verify_push', request); const options = request.payload; const sessionToken = request.auth.credentials; const { uid, email } = sessionToken; const { code, tokenVerificationId } = options; await customs.check(request, email, 'verifySessionCode'); request.emitMetricsEvent('session.verify_push'); const device = await db.deviceFromTokenVerificationId( uid, tokenVerificationId ); // If device is not found, this means the device has already been verified. // Since the user can not take any additional action, it is safe to return // a successful response. if (!device) { return {}; } // Check to see if the otp code passed matches the expected value from // using the account's' `emailCode` as the secret in the otp code generation. const account = await db.account(uid); const secret = account.primaryEmail.emailCode; const isValidCode = otpUtils.verifyOtpCode( code, secret, otpOptions, 'session.verify_push' ); if (!isValidCode) { throw error.invalidOrExpiredOtpCode(); } await db.verifyTokens(tokenVerificationId, account); // We have a matching code! Let's verify session and send the // corresponding email and emit metrics. request.emitMetricsEvent('account.confirmed', { uid }); glean.login.verifyCodeConfirmed(request, { uid }); await signinUtils.cleanupReminders({ verified: true }, account); const devices = await db.devices(uid); await push.notifyAccountUpdated(uid, devices, 'accountConfirm'); return {}; }, }, ]; return routes; };