packages/fxa-auth-server/lib/routes/password.ts (1,073 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/. */
import { emailsMatch } from 'fxa-shared/email/helpers';
import { StatsD } from 'hot-shots';
import { Redis } from 'ioredis';
import * as isA from 'joi';
import { OtpManager, OtpStorage } from '@fxa/shared/otp';
import { ConfigType } from '../../config';
import PASSWORD_DOCS from '../../docs/swagger/password-api';
import DESCRIPTION from '../../docs/swagger/shared/descriptions';
import * as butil from '../crypto/butil';
import * as random from '../crypto/random';
import * as error from '../error';
import { schema as METRICS_CONTEXT_SCHEMA } from '../metrics/context';
import { gleanMetrics } from '../metrics/glean';
import * as requestHelper from '../routes/utils/request_helper';
import { AuthLogger, AuthRequest } from '../types';
import { recordSecurityEvent } from './utils/security-event';
import * as validators from './validators';
const HEX_STRING = validators.HEX_STRING;
class OtpRedisAdapter implements OtpStorage {
constructor(
private redis: Redis,
private ttl: number
) {}
async set(key: string, value: string) {
await this.redis.set(key, value, 'EX', this.ttl);
}
async get(key: string) {
return this.redis.get(key);
}
async del(key: string) {
await this.redis.del(key);
}
}
module.exports = function (
log: AuthLogger,
db: any,
Password: any,
redirectDomain: any,
mailer: any,
verifierVersion: any,
customs: any,
signinUtils: any,
push: any,
config: ConfigType,
oauth: any,
glean: ReturnType<typeof gleanMetrics>,
authServerCacheRedis: Redis,
statsd: StatsD
) {
const otpUtils = require('../../lib/routes/utils/otp')(
log,
config,
db,
statsd
);
const otpRedisAdapter = new OtpRedisAdapter(
authServerCacheRedis,
config.passwordForgotOtp.ttl
);
const otpManager = new OtpManager(
{ kind: 'passwordForgot', digits: config.passwordForgotOtp.digits },
otpRedisAdapter
);
function failVerifyAttempt(passwordForgotToken: any) {
return passwordForgotToken.failAttempt()
? db.deletePasswordForgotToken(passwordForgotToken)
: db.updatePasswordForgotToken(passwordForgotToken);
}
const routes = [
{
method: 'POST',
path: '/password/change/start',
options: {
...PASSWORD_DOCS.PASSWORD_CHANGE_START_POST,
auth: {
mode: 'optional',
strategy: 'sessionToken',
payload: 'required',
},
validate: {
payload: isA.object({
email: validators.email().required().description(DESCRIPTION.email),
oldAuthPW: validators.authPW.description(DESCRIPTION.authPW),
}),
},
},
handler: async function (request: AuthRequest) {
log.begin('Password.changeStart', request);
const form = request.payload as { email: string; oldAuthPW: string };
const { oldAuthPW, email } = form;
const sessionToken = request.auth.credentials;
if (sessionToken) {
await customs.checkAuthenticated(
request,
sessionToken.uid,
'passwordChange'
);
statsd.increment('passwordChange.start.authenticated');
} else {
await customs.check(request, email, 'passwordChange');
statsd.increment('passwordChange.start.unauthenticated');
}
let keyFetchToken: any | undefined = undefined;
let keyFetchToken2: any | undefined = undefined;
let passwordChangeToken: any | undefined = undefined;
try {
const emailRecord = await db.accountRecord(email);
const password = new Password(
oldAuthPW,
emailRecord.authSalt,
emailRecord.verifierVersion
);
const match = await signinUtils.checkPassword(
emailRecord,
password,
request.app.clientAddress
);
if (!match) {
throw error.incorrectPassword(emailRecord.email, form.email);
}
if (password.clientVersion === 1) {
const unwrappedKb = await password.unwrap(emailRecord.wrapWrapKb);
keyFetchToken = await db.createKeyFetchToken({
uid: emailRecord.uid,
kA: emailRecord.kA,
wrapKb: unwrappedKb,
emailVerified: emailRecord.emailVerified,
});
}
if (password.clientVersion === 2) {
const unwrappedKb = await password.unwrap(
emailRecord.wrapWrapKbVersion2
);
keyFetchToken2 = await db.createKeyFetchToken({
uid: emailRecord.uid,
kA: emailRecord.kA,
wrapKb: unwrappedKb,
emailVerified: emailRecord.emailVerified,
});
}
passwordChangeToken = await db.createPasswordChangeToken({
uid: emailRecord.uid,
});
} catch (err) {
if (err.errno === error.ERRNO.ACCOUNT_UNKNOWN) {
customs.flag(request.app.clientAddress, {
email: form.email,
errno: err.errno,
});
}
throw err;
}
return {
keyFetchToken: keyFetchToken?.data,
keyFetchToken2: keyFetchToken2?.data,
passwordChangeToken: passwordChangeToken?.data,
verified:
keyFetchToken?.emailVerified || keyFetchToken2?.emailVerified,
};
},
},
{
method: 'POST',
path: '/password/change/finish',
options: {
...PASSWORD_DOCS.PASSWORD_CHANGE_FINISH_POST,
auth: {
strategy: 'passwordChangeToken',
payload: 'required',
},
validate: {
query: isA.object({
keys: isA.boolean().optional().description(DESCRIPTION.queryKeys),
}),
payload: isA
.object({
authPW: validators.authPW.description(DESCRIPTION.authPW),
authPWVersion2: validators.authPW
.optional()
.description(DESCRIPTION.authPW),
wrapKb: validators.wrapKb
.optional()
.description(DESCRIPTION.wrapKb),
wrapKbVersion2: validators.wrapKb
.optional()
.description(DESCRIPTION.wrapKbVersion2),
clientSalt: validators.clientSalt
.optional()
.description(DESCRIPTION.clientSalt),
sessionToken: isA
.string()
.min(64)
.max(64)
.regex(HEX_STRING)
.optional()
.description(DESCRIPTION.sessionToken),
})
.and('authPWVersion2', 'wrapKbVersion2', 'clientSalt'),
},
},
handler: async function (request: AuthRequest) {
log.begin('Password.changeFinish', request);
const passwordChangeToken = request.auth.credentials;
const {
authPW,
authPWVersion2,
wrapKb,
wrapKbVersion2,
clientSalt,
sessionToken: sessionTokenId,
} = request.payload as {
authPW: string;
authPWVersion2?: string;
wrapKb?: string;
wrapKbVersion2?: string;
clientSalt?: string;
sessionToken?: string;
};
const wantsKeys = requestHelper.wantsKeys(request);
const ip = request.app.clientAddress;
const hasTotp = await checkTotpToken();
const { verifiedStatus, previousSessionToken, originatingDeviceId } =
await getSessionVerificationStatus();
const devicesToNotify = await fetchDevicesToNotify();
const { account, isPasswordUpgrade } = await changePassword();
await notifyAccount();
const sessionToken = await createSessionToken();
await verifySessionToken();
const { keyFetchToken, keyFetchToken2 } = await createKeyFetchToken();
return createResponse();
async function checkTotpToken() {
const hasTotp = await otpUtils.hasTotpToken(passwordChangeToken);
// Currently, users that have a TOTP token must specify a sessionTokenId to complete the
// password change process. While the `sessionTokenId` is optional, we require it
// by TOTP.
if (hasTotp && !sessionTokenId) {
throw error.unverifiedSession();
}
return hasTotp;
}
async function getSessionVerificationStatus() {
const result: {
verifiedStatus: boolean;
previousSessionToken?: any;
originatingDeviceId?: any;
} = { verifiedStatus: false };
if (!sessionTokenId) {
// Don't create a verified session unless they already had one.
result.verifiedStatus = false;
return result;
}
const tokenData = await db.sessionToken(sessionTokenId);
result.previousSessionToken = tokenData;
result.verifiedStatus = tokenData.tokenVerified;
if (tokenData.deviceId) {
result.originatingDeviceId = tokenData.deviceId;
}
if (hasTotp && tokenData.authenticatorAssuranceLevel <= 1) {
throw error.unverifiedSession();
}
return result;
}
async function fetchDevicesToNotify() {
// We fetch the devices to notify before changePassword() because
// db.resetAccount() deletes all the devices saved in the account.
const devices = await request.app.devices;
// If the originating sessionToken belongs to a device,
// do not send the notification to that device. It will
// get informed about the change via WebChannel message.
if (originatingDeviceId) {
return devices.filter((d: any) => d.id !== originatingDeviceId);
}
return devices;
}
async function changePassword() {
const authSalt = await random.hex(32);
const password = new Password(authPW, authSalt, verifierVersion);
const verifyHash = await password.verifyHash();
const account = await db.account(passwordChangeToken.uid);
const wrapWrapKb = await password.wrap(wrapKb);
let isPasswordUpgrade = false;
if (
authPWVersion2 &&
!/quickStretchV2/.test(account.clientSalt || '')
) {
const v1Password = new Password(
authPW,
account.authSalt,
account.verifierVersion
);
isPasswordUpgrade = await signinUtils.checkPassword(
account,
v1Password,
request.app.clientAddress
);
}
// For the time being we store both passwords in the DB. authPW is created
// with the old quickStretch and authPWVersion2 is created with improved 'quick' stretch.
let password2: any | undefined = undefined;
let verifyHashVersion2 = undefined;
let wrapWrapKbVersion2 = undefined;
if (authPWVersion2) {
password2 = new Password(
authPWVersion2,
authSalt,
verifierVersion,
2
);
verifyHashVersion2 = await password2?.verifyHash();
wrapWrapKbVersion2 = await password2?.wrap(wrapKbVersion2);
}
await db.deletePasswordChangeToken(passwordChangeToken);
if (isPasswordUpgrade) {
const result = await db.resetAccount(
passwordChangeToken,
{
authSalt: authSalt,
clientSalt: clientSalt,
verifierVersion: password.version,
verifyHash: verifyHash,
verifyHashVersion2: verifyHashVersion2,
wrapWrapKb: wrapWrapKb,
wrapWrapKbVersion2: wrapWrapKbVersion2,
keysHaveChanged: false,
isPasswordUpgrade: true,
},
true
);
await request.emitMetricsEvent('account.upgradedPassword', {
uid: passwordChangeToken.uid,
});
recordSecurityEvent('account.password_upgrade_success', {
db,
request,
account: passwordChangeToken,
});
recordSecurityEvent('account.password_upgraded', {
db,
request,
account: passwordChangeToken,
});
return { result, account, isPasswordUpgrade };
}
const result = await db.resetAccount(passwordChangeToken, {
authSalt: authSalt,
clientSalt: clientSalt,
verifierVersion: password.version,
verifyHash: verifyHash,
verifyHashVersion2: verifyHashVersion2,
wrapWrapKb: wrapWrapKb,
wrapWrapKbVersion2: wrapWrapKbVersion2,
keysHaveChanged: false,
});
await request.emitMetricsEvent('account.changedPassword', {
uid: passwordChangeToken.uid,
});
recordSecurityEvent('account.password_reset_success', {
db,
request,
account: passwordChangeToken,
});
recordSecurityEvent('account.password_changed', {
db,
request,
account: passwordChangeToken,
});
return { result, account, isPasswordUpgrade };
}
async function notifyAccount() {
// When upgrading passwords, the previous password is still
// valid, and therefore we can short circuit the notification
// processes.
if (isPasswordUpgrade) {
return;
}
if (devicesToNotify) {
// Notify the devices that the account has changed.
push.notifyPasswordChanged(
passwordChangeToken.uid,
devicesToNotify
);
}
log.notifyAttachedServices('passwordChange', request, {
uid: passwordChangeToken.uid,
generation: account.verifierSetAt,
});
await oauth.removePublicAndCanGrantTokens(passwordChangeToken.uid);
const emails = await db.accountEmails(passwordChangeToken.uid);
const geoData = request.app.geo;
const {
browser: uaBrowser,
browserVersion: uaBrowserVersion,
os: uaOS,
osVersion: uaOSVersion,
deviceType: uaDeviceType,
} = request.app.ua;
try {
await mailer.sendPasswordChangedEmail(emails, account, {
acceptLanguage: request.app.acceptLanguage,
ip,
location: geoData.location,
timeZone: geoData.timeZone,
uaBrowser,
uaBrowserVersion,
uaOS,
uaOSVersion,
uaDeviceType,
uid: passwordChangeToken.uid,
});
} catch (error) {
// If we couldn't email them, no big deal. Log
// and pretend everything worked.
log.trace(
'Password.changeFinish.sendPasswordChangedNotification.error',
{
error,
}
);
}
}
async function createSessionToken() {
const maybeToken = !verifiedStatus ? await random.hex(16) : undefined;
const {
browser: uaBrowser,
browserVersion: uaBrowserVersion,
os: uaOS,
osVersion: uaOSVersion,
deviceType: uaDeviceType,
formFactor: uaFormFactor,
} = request.app.ua;
// Create a sessionToken with the verification status of the current session
const sessionTokenOptions = {
uid: account.uid,
email: account.email,
emailCode: account.emailCode,
emailVerified: account.emailVerified,
verifierSetAt: account.verifierSetAt,
mustVerify: wantsKeys,
tokenVerificationId: maybeToken,
uaBrowser,
uaBrowserVersion,
uaOS,
uaOSVersion,
uaDeviceType,
uaFormFactor,
};
return db.createSessionToken(sessionTokenOptions);
}
function verifySessionToken() {
if (
sessionToken &&
previousSessionToken &&
previousSessionToken.verificationMethodValue
) {
return db.verifyTokensWithMethod(
sessionToken.id,
previousSessionToken.verificationMethodValue
);
}
}
async function createKeyFetchToken() {
const result: {
keyFetchToken?: any;
keyFetchToken2?: any;
} = {};
if (wantsKeys) {
// Create a verified keyFetchToken. This is deliberately verified because we don't
// want to perform an email confirmation loop.
if (authPW) {
result.keyFetchToken = await db.createKeyFetchToken({
uid: account.uid,
kA: account.kA,
wrapKb: wrapKb,
emailVerified: account.emailVerified,
});
}
if (authPWVersion2) {
result.keyFetchToken2 = await db.createKeyFetchToken({
uid: account.uid,
kA: account.kA,
wrapKb: wrapKbVersion2,
emailVerified: account.emailVerified,
});
}
}
return result;
}
function createResponse() {
// If no sessionToken, this could be a legacy client
// attempting to change password, return legacy response.
if (!sessionTokenId) {
return {};
}
const response: any = {
uid: sessionToken.uid,
sessionToken: sessionToken.data,
verified: sessionToken.emailVerified && sessionToken.tokenVerified,
authAt: sessionToken.lastAuthAt(),
};
if (wantsKeys) {
if (keyFetchToken) {
response.keyFetchToken = keyFetchToken.data;
}
if (keyFetchToken2) {
response.keyFetchToken2 = keyFetchToken2.data;
}
}
return response;
}
},
},
{
// This endpoint will eventually replace '/password/forgot/send_code'
// below. That is also the reason for the similarity between them.
method: 'POST',
path: '/password/forgot/send_otp',
options: {
...PASSWORD_DOCS.PASSWORD_FORGOT_SEND_OTP_POST,
validate: {
query: isA.object({
service: validators.service.description(DESCRIPTION.serviceRP),
keys: isA.boolean().optional(),
}),
payload: isA.object({
email: validators
.email()
.required()
.description(DESCRIPTION.emailRecovery),
service: validators.service.description(DESCRIPTION.serviceRP),
metricsContext: METRICS_CONTEXT_SCHEMA,
}),
},
},
handler: async function (request: AuthRequest) {
log.begin('Password.forgotOtp', request);
await request.emitMetricsEvent('password.forgot.send_otp.start');
const payload = request.payload as {
email: string;
service: string;
metricsContext: any;
};
const email = payload.email;
await customs.check(request, email, 'passwordForgotSendOtp');
request.validateMetricsContext();
const account = await db.accountRecord(email);
if (!emailsMatch(account.primaryEmail.normalizedEmail, email)) {
throw error.cannotResetPasswordWithSecondaryEmail();
}
let flowCompleteSignal;
if (requestHelper.wantsKeys(request)) {
flowCompleteSignal = 'account.signed';
} else {
flowCompleteSignal = 'account.reset';
}
request.setMetricsFlowCompleteSignal(flowCompleteSignal);
const code = await otpManager.create(account.uid);
const ip = request.app.clientAddress;
const service = payload.service || request.query.service;
const { deviceId, flowId, flowBeginTime } =
await request.app.metricsContext;
const geoData = request.app.geo;
const {
browser: uaBrowser,
browserVersion: uaBrowserVersion,
os: uaOS,
osVersion: uaOSVersion,
deviceType: uaDeviceType,
} = request.app.ua;
await mailer.sendPasswordForgotOtpEmail(account.emails, account, {
code,
service,
acceptLanguage: request.app.acceptLanguage,
deviceId,
flowId,
flowBeginTime,
ip,
location: geoData.location,
timeZone: geoData.timeZone,
uaBrowser,
uaBrowserVersion,
uaOS,
uaOSVersion,
uaDeviceType,
uid: account.uid,
});
glean.resetPassword.otpEmailSent(request);
await request.emitMetricsEvent('password.forgot.send_otp.completed');
recordSecurityEvent('account.password_reset_otp_sent', {
db,
request,
account: { uid: account.uid },
});
statsd.increment('otp.passwordForgot.sent');
return {};
},
},
{
method: 'POST',
path: '/password/forgot/verify_otp',
options: {
...PASSWORD_DOCS.PASSWORD_FORGOT_VERIFY_OTP_POST,
validate: {
payload: isA.object({
email: validators
.email()
.required()
.description(DESCRIPTION.emailRecovery),
code: isA
.string()
.length(config.passwordForgotOtp.digits)
.regex(validators.DIGITS),
metricsContext: METRICS_CONTEXT_SCHEMA,
}),
},
},
handler: async function (request: any) {
log.begin('Password.forgotOtpVerify', request);
await request.emitMetricsEvent('password.forgot.verify_otp.start');
statsd.increment('otp.passwordForgot.attempt');
const { email, code } = request.payload;
await customs.check(request, email, 'passwordForgotVerifyOtp');
request.validateMetricsContext();
const account = await db.accountRecord(email);
const isValidCode = await otpManager.isValid(account.uid, code);
if (!isValidCode) {
throw error.invalidVerificationCode();
}
const passwordForgotToken = await db.createPasswordForgotToken(account);
await otpManager.delete(account.uid);
glean.resetPassword.otpVerified(request);
await request.emitMetricsEvent('password.forgot.verify_otp.completed');
recordSecurityEvent('account.password_reset_otp_verified', {
db,
request,
account: { uid: account.uid },
});
statsd.increment('otp.passwordForgot.verified');
return {
code: passwordForgotToken.passCode,
emailToHashWith: passwordForgotToken.email,
token: passwordForgotToken.data,
uid: passwordForgotToken.uid,
};
},
},
{
method: 'POST',
path: '/password/forgot/send_code',
options: {
...PASSWORD_DOCS.PASSWORD_FORGOT_SEND_CODE_POST,
validate: {
query: isA.object({
service: validators.service.description(DESCRIPTION.serviceRP),
keys: isA.boolean().optional(),
}),
payload: isA.object({
email: validators
.email()
.required()
.description(DESCRIPTION.emailRecovery),
service: validators.service.description(DESCRIPTION.serviceRP),
redirectTo: validators
.redirectTo(redirectDomain)
.optional()
.description(DESCRIPTION.redirectTo),
resume: isA
.string()
.max(2048)
.optional()
.description(DESCRIPTION.resume),
metricsContext: METRICS_CONTEXT_SCHEMA,
}),
},
response: {
schema: isA.object({
passwordForgotToken: isA.string(),
ttl: isA.number(),
codeLength: isA.number(),
tries: isA.number(),
}),
},
},
handler: async function (request: AuthRequest) {
log.begin('Password.forgotSend', request);
const payload = request.payload as {
email: string;
service: string;
redirectTo?: string;
resume?: string;
};
const email = payload.email;
const service = payload.service || request.query.service;
const ip = request.app.clientAddress;
request.validateMetricsContext();
let flowCompleteSignal;
if (requestHelper.wantsKeys(request)) {
flowCompleteSignal = 'account.signed';
} else {
flowCompleteSignal = 'account.reset';
}
request.setMetricsFlowCompleteSignal(flowCompleteSignal);
const { deviceId, flowId, flowBeginTime } =
await request.app.metricsContext;
await Promise.all([
request.emitMetricsEvent('password.forgot.send_code.start'),
customs.check(request, email, 'passwordForgotSendCode'),
]);
const accountRecord = await db.accountRecord(email);
if (!emailsMatch(accountRecord.primaryEmail.normalizedEmail, email)) {
throw error.cannotResetPasswordWithSecondaryEmail();
}
// The token constructor sets createdAt from its argument.
// Clobber the timestamp to prevent prematurely expired tokens.
accountRecord.createdAt = undefined;
const passwordForgotToken =
await db.createPasswordForgotToken(accountRecord);
const [, emails] = await Promise.all([
request.stashMetricsContext(passwordForgotToken),
db.accountEmails(passwordForgotToken.uid),
]);
const geoData = request.app.geo;
const {
browser: uaBrowser,
browserVersion: uaBrowserVersion,
os: uaOS,
osVersion: uaOSVersion,
deviceType: uaDeviceType,
} = request.app.ua;
await mailer.sendRecoveryEmail(emails, passwordForgotToken, {
emailToHashWith: passwordForgotToken.email,
token: passwordForgotToken.data,
code: passwordForgotToken.passCode,
service: service,
redirectTo: payload.redirectTo,
resume: payload.resume,
acceptLanguage: request.app.acceptLanguage,
deviceId,
flowId,
flowBeginTime,
ip,
location: geoData.location,
timeZone: geoData.timeZone,
uaBrowser,
uaBrowserVersion,
uaOS,
uaOSVersion,
uaDeviceType,
uid: passwordForgotToken.uid,
});
await Promise.all([
request.emitMetricsEvent('password.forgot.send_code.completed'),
glean.resetPassword.emailSent(request),
]);
return {
passwordForgotToken: passwordForgotToken.data,
ttl: passwordForgotToken.ttl(),
codeLength: passwordForgotToken.passCode.length,
tries: passwordForgotToken.tries,
};
},
},
{
method: 'POST',
path: '/password/forgot/resend_code',
options: {
...PASSWORD_DOCS.PASSWORD_FORGOT_RESEND_CODE_POST,
auth: {
strategy: 'passwordForgotToken',
payload: 'required',
},
validate: {
query: isA.object({
service: validators.service.description(DESCRIPTION.serviceRP),
}),
payload: isA.object({
email: validators
.email()
.required()
.description(DESCRIPTION.emailRecovery),
service: validators.service.description(DESCRIPTION.serviceRP),
redirectTo: validators
.redirectTo(redirectDomain)
.optional()
.description(DESCRIPTION.redirectTo),
resume: isA
.string()
.max(2048)
.optional()
.description(DESCRIPTION.resume),
}),
},
response: {
schema: isA.object({
passwordForgotToken: isA.string(),
ttl: isA.number(),
codeLength: isA.number(),
tries: isA.number(),
}),
},
},
handler: async function (request: AuthRequest) {
log.begin('Password.forgotResend', request);
const passwordForgotToken = request.auth.credentials as any;
const payload = request.payload as {
email: string;
service: string;
redirectTo?: string;
resume?: string;
};
const service = payload.service || request.query.service;
const ip = request.app.clientAddress;
const { deviceId, flowId, flowBeginTime } =
await request.app.metricsContext;
await Promise.all([
request.emitMetricsEvent('password.forgot.resend_code.start'),
customs.check(
request,
passwordForgotToken.email,
'passwordForgotResendCode'
),
]);
const emails = await db.accountEmails(passwordForgotToken.uid);
const geoData = request.app.geo;
const {
browser: uaBrowser,
browserVersion: uaBrowserVersion,
os: uaOS,
osVersion: uaOSVersion,
deviceType: uaDeviceType,
} = request.app.ua;
await mailer.sendRecoveryEmail(emails, passwordForgotToken, {
code: passwordForgotToken.passCode,
emailToHashWith: passwordForgotToken.email,
token: passwordForgotToken.data,
service,
redirectTo: payload.redirectTo,
resume: payload.resume,
acceptLanguage: request.app.acceptLanguage,
deviceId,
flowId,
flowBeginTime,
ip,
location: geoData.location,
timeZone: geoData.timeZone,
uaBrowser,
uaBrowserVersion,
uaOS,
uaOSVersion,
uaDeviceType,
uid: passwordForgotToken.uid,
});
await Promise.all([
request.emitMetricsEvent('password.forgot.resend_code.completed'),
recordSecurityEvent('account.password_reset_requested', {
db,
request,
}),
]);
return {
passwordForgotToken: passwordForgotToken.data,
ttl: passwordForgotToken.ttl(),
codeLength: passwordForgotToken.passCode.length,
tries: passwordForgotToken.tries,
};
},
},
{
method: 'POST',
path: '/password/forgot/verify_code',
options: {
...PASSWORD_DOCS.PASSWORD_FORGOT_VERIFY_CODE_POST,
auth: {
strategy: 'passwordForgotToken',
payload: 'required',
},
validate: {
payload: isA.object({
code: isA
.string()
.min(32)
.max(32)
.regex(HEX_STRING)
.required()
.description(DESCRIPTION.codeRecovery),
accountResetWithRecoveryKey: isA.boolean().optional(),
includeRecoveryKeyPrompt: isA.boolean().optional(),
}),
},
response: {
schema: isA.object({
accountResetToken: isA.string(),
}),
},
},
handler: async function (request: AuthRequest) {
log.begin('Password.forgotVerify', request);
const passwordForgotToken = request.auth.credentials as any;
const { code, accountResetWithRecoveryKey, includeRecoveryKeyPrompt } =
request.payload as {
code: string;
accountResetWithRecoveryKey?: boolean;
includeRecoveryKeyPrompt?: boolean;
};
const { deviceId, flowId, flowBeginTime } =
await request.app.metricsContext;
await Promise.all([
request.emitMetricsEvent('password.forgot.verify_code.start'),
customs.check(
request,
passwordForgotToken.email,
'passwordForgotVerifyCode'
),
]);
let accountResetToken;
if (
butil.buffersAreEqual(passwordForgotToken.passCode, code) &&
passwordForgotToken.ttl() > 0
) {
accountResetToken =
await db.forgotPasswordVerified(passwordForgotToken);
} else {
await failVerifyAttempt(passwordForgotToken);
throw error.invalidVerificationCode({
tries: passwordForgotToken.tries,
ttl: passwordForgotToken.ttl(),
});
}
const [, emails] = await Promise.all([
request.propagateMetricsContext(
passwordForgotToken,
accountResetToken
),
db.accountEmails(passwordForgotToken.uid),
]);
const {
browser: uaBrowser,
browserVersion: uaBrowserVersion,
os: uaOS,
osVersion: uaOSVersion,
deviceType: uaDeviceType,
} = request.app.ua;
const emailOptions = {
code,
acceptLanguage: request.app.acceptLanguage,
deviceId,
flowId,
flowBeginTime,
uaBrowser,
uaBrowserVersion,
uaOS,
uaOSVersion,
uaDeviceType,
uid: passwordForgotToken.uid,
};
// To prevent multiple password change emails being sent to a user,
// we check for a flag to see if this is a reset using an account recovery key.
// If it is, then the notification email will be sent in `/account/reset`
if (!accountResetWithRecoveryKey) {
if (includeRecoveryKeyPrompt) {
await mailer.sendPasswordResetWithRecoveryKeyPromptEmail(
emails,
passwordForgotToken,
emailOptions
);
} else {
await mailer.sendPasswordResetEmail(
emails,
passwordForgotToken,
emailOptions
);
}
}
await request.emitMetricsEvent('password.forgot.verify_code.completed');
return {
accountResetToken: accountResetToken.data,
};
},
},
{
method: 'POST',
path: '/password/create',
options: {
...PASSWORD_DOCS.PASSWORD_CREATE_POST,
auth: {
strategy: 'sessionToken',
},
validate: {
payload: isA
.object({
authPW: validators.authPW.description(DESCRIPTION.authPW),
authPWVersion2: validators.authPWVersion2
.optional()
.description(DESCRIPTION.authPWVersion2),
wrapKb: validators.wrapKb
.optional()
.description(DESCRIPTION.wrapKb),
wrapKbVersion2: validators.wrapKb
.optional()
.description(DESCRIPTION.wrapKbVersion2),
clientSalt: validators.clientSalt
.optional()
.description(DESCRIPTION.clientSalt),
})
.and('authPWVersion2', 'wrapKb', 'wrapKbVersion2', 'clientSalt'),
},
},
handler: async function (request: AuthRequest) {
log.begin('Password.create', request);
const sessionToken = request.auth.credentials as any;
const { uid } = sessionToken;
const { authPW, authPWVersion2, wrapKb, wrapKbVersion2, clientSalt } =
request.payload as {
authPW: string;
authPWVersion2?: string;
wrapKb?: string;
wrapKbVersion2?: string;
clientSalt?: string;
};
const account = await db.account(uid);
// We don't allow users that have a password set already to create a new password
// because this process would destroy their original encryption keys and might
// leave the account in an invalid state.
if (account.verifierSetAt > 0) {
throw error.cannotCreatePassword();
}
// Users that have enabled 2FA must be in a 2FA verified session to create a password.
const hasTotpToken = await otpUtils.hasTotpToken(account);
if (
hasTotpToken &&
(sessionToken.tokenVerificationId ||
sessionToken.authenticatorAssuranceLevel <= 1)
) {
throw error.unverifiedSession();
}
const authSalt = await random.hex(32);
// For V1 credentials
const password = new Password(authPW, authSalt, config.verifierVersion);
const verifyHash = await password.verifyHash();
let wrapWrapKb = undefined;
// For V2 credentials
let wrapWrapKbVersion2 = undefined;
let verifyHashVersion2 = undefined;
if (authPWVersion2) {
const password2 = new Password(
authPWVersion2,
authSalt,
config.verifierVersion
);
wrapWrapKbVersion2 = await password2.wrap(wrapKbVersion2);
verifyHashVersion2 = await password2.verifyHash();
// Important! For V2 credentials, wrapKb and wrapKbVersion2 are supplied by client
// to ensure that a single kB results from either password. Therefore, we
// must used supplied wrapKb
wrapWrapKb = await password.wrap(wrapKb);
} else {
wrapWrapKb = await random.hex(32);
}
const passwordCreated = await db.createPassword(
uid,
authSalt,
clientSalt,
verifyHash,
verifyHashVersion2,
wrapWrapKb,
wrapWrapKbVersion2,
verifierVersion
);
recordSecurityEvent('account.password_added', {
db,
request,
account: { uid },
});
// We need to track when users with third party accounts actually set a passwords. Note that
// this handler will exit early if the user already has a password, and this code would not
// be reached, which ensures this event will only fire when a user is setting their linked
// account password for the first time.
const linkedAccounts = await db.getLinkedAccounts(uid);
if (
linkedAccounts?.length > 0 &&
linkedAccounts.some((x: { enabled: boolean }) => x.enabled)
) {
glean.thirdPartyAuth.setPasswordComplete(request, {
uid,
});
}
return passwordCreated;
},
},
{
method: 'GET',
path: '/password/forgot/status',
options: {
...PASSWORD_DOCS.PASSWORD_FORGOT_STATUS_GET,
auth: {
strategy: 'passwordForgotToken',
},
response: {
schema: isA.object({
tries: isA.number(),
ttl: isA.number(),
}),
},
},
handler: async function (request: AuthRequest) {
log.begin('Password.forgotStatus', request);
const passwordForgotToken = request.auth.credentials as any;
return {
tries: passwordForgotToken.tries,
ttl: passwordForgotToken.ttl(),
};
},
},
];
return routes;
};