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