async reset()

in packages/fxa-auth-server/lib/routes/account.ts [1479:1786]


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