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