module.exports = function()

in packages/fxa-auth-server/lib/routes/session.js [20:671]


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;
};