async login()

in packages/fxa-auth-server/lib/routes/account.ts [891:1316]


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