diff --git a/packages/fxa-auth-server/config/index.ts b/packages/fxa-auth-server/config/index.ts index 8a8ce79aef2..1db955e7c0c 100644 --- a/packages/fxa-auth-server/config/index.ts +++ b/packages/fxa-auth-server/config/index.ts @@ -2161,6 +2161,32 @@ const convictConf = convict({ env: 'OTP_SIGNUP_DIGIT', }, }, + passwordlessOtp: { + enabled: { + doc: 'Enable passwordless authentication feature', + default: false, + format: Boolean, + env: 'PASSWORDLESS_ENABLED', + }, + forcedEmailAddresses: { + doc: 'Force passwordless flow for email addresses matching this regex (for testing)', + format: RegExp, + default: /^passwordless.*@restmail\.net$/, + env: 'PASSWORDLESS_FORCED_EMAIL_REGEX', + }, + digits: { + doc: 'Number of digits in passwordless OTP code', + default: 8, + format: 'nat', + env: 'OTP_PASSWORDLESS_DIGITS', + }, + ttl: { + doc: 'Duration in seconds when the passwordless OTP is valid', + default: 10 * 60, + format: 'nat', + env: 'OTP_PASSWORDLESS_TTL', + }, + }, accountDestroy: { requireVerifiedAccount: { doc: 'Whether or not the account must be verified in order to destroy it.', diff --git a/packages/fxa-auth-server/config/rate-limit-rules.txt b/packages/fxa-auth-server/config/rate-limit-rules.txt index 6d4ba394ea3..cb11f3775d1 100644 --- a/packages/fxa-auth-server/config/rate-limit-rules.txt +++ b/packages/fxa-auth-server/config/rate-limit-rules.txt @@ -156,3 +156,19 @@ mfaOtpCodeVerifyForEmail : uid : 5 : 5 minu mfaOtpCodeVerifyFor2fa : uid : 5 : 5 minutes : 15 minutes : block mfaOtpCodeVerifyForPassword : uid : 5 : 5 minutes : 15 minutes : block mfaOtpCodeVerifyForRecoveryKey : uid : 5 : 5 minutes : 15 minutes : block + +# +# Passwordless Authentication OTP Limits +# Controls the rate at which passwordless OTP codes can be sent and verified +# +passwordlessSendOtp : email : 2 : 15 minutes : 15 minutes : block +passwordlessSendOtp : email : 5 : 24 hours : 12 hours : block +passwordlessSendOtp : ip : 50 : 24 hours : 12 hours : block +passwordlessSendOtp : ip : 20 : 15 minutes : 30 minutes : block +passwordlessSendOtp : ip : 100 : 24 hours : 15 minutes : ban + +# Passwordless OTP Verification Limits +passwordlessVerifyOtp : ip_email : 5 : 10 minutes : 15 minutes : block +passwordlessVerifyOtp : ip : 100 : 24 hours : 15 minutes : ban +passwordlessVerifyOtpPerDay : ip_email : 10 : 24 hours : 24 hours : block +passwordlessVerifyOtpPerDay : ip : 100 : 24 hours : 15 minutes : ban diff --git a/packages/fxa-auth-server/docs/swagger/passwordless-api.ts b/packages/fxa-auth-server/docs/swagger/passwordless-api.ts new file mode 100644 index 00000000000..c113717e7ae --- /dev/null +++ b/packages/fxa-auth-server/docs/swagger/passwordless-api.ts @@ -0,0 +1,107 @@ +/* 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 dedent from 'dedent'; +import TAGS from './swagger-tags'; + +const TAGS_PASSWORDLESS = { + tags: TAGS.PASSWORDLESS, +}; + +const PASSWORDLESS_SEND_CODE_POST = { + ...TAGS_PASSWORDLESS, + description: '/account/passwordless/send_code', + notes: [ + dedent` + Send a one-time password (OTP) code to the user's email for passwordless authentication. + + This endpoint can be used for both: + - New user registration (account doesn't exist) + - Login for existing passwordless accounts (accounts without a password) + + Accounts with passwords set cannot use this endpoint. + `, + ], + plugins: { + 'hapi-swagger': { + responses: { + 400: { + description: dedent` + Failing requests may be caused by the following errors: + - \`errno: 148\` - Account has a password set, use standard login flow + `, + }, + 429: { + description: 'Rate limit exceeded', + }, + }, + }, + }, +}; + +const PASSWORDLESS_CONFIRM_CODE_POST = { + ...TAGS_PASSWORDLESS, + description: '/account/passwordless/confirm_code', + notes: [ + dedent` + Confirm the OTP code sent via \`/account/passwordless/send_code\`. + + On success: + - For new users: Creates a new account and returns a session token + - For existing users: Returns a session token for the existing account + + The \`isNewAccount\` field in the response indicates whether a new account was created. + `, + ], + plugins: { + 'hapi-swagger': { + responses: { + 400: { + description: dedent` + Failing requests may be caused by the following errors: + - \`errno: 183\` - Invalid OTP code + - \`errno: 148\` - Account has a password set + `, + }, + 429: { + description: 'Rate limit exceeded', + }, + }, + }, + }, +}; + +const PASSWORDLESS_RESEND_CODE_POST = { + ...TAGS_PASSWORDLESS, + description: '/account/passwordless/resend_code', + notes: [ + dedent` + Resend the OTP code for passwordless authentication. + + This invalidates any previously sent code and sends a new one. + Subject to the same rate limits as \`/account/passwordless/send_code\`. + `, + ], + plugins: { + 'hapi-swagger': { + responses: { + 400: { + description: dedent` + Failing requests may be caused by the following errors: + - \`errno: 148\` - Account has a password set + `, + }, + 429: { + description: 'Rate limit exceeded', + }, + }, + }, + }, +}; + +export default { + PASSWORDLESS_SEND_CODE_POST, + PASSWORDLESS_CONFIRM_CODE_POST, + PASSWORDLESS_RESEND_CODE_POST, +}; diff --git a/packages/fxa-auth-server/docs/swagger/swagger-tags.ts b/packages/fxa-auth-server/docs/swagger/swagger-tags.ts index 57abc12adf3..97960be9fd0 100644 --- a/packages/fxa-auth-server/docs/swagger/swagger-tags.ts +++ b/packages/fxa-auth-server/docs/swagger/swagger-tags.ts @@ -11,6 +11,7 @@ const TAGS = { OAUTH: ['api', 'Oauth'], OAUTH_SERVER: ['api', 'OAuth Server API Overview'], PASSWORD: ['api', 'Password'], + PASSWORDLESS: ['api', 'Passwordless'], RECOVERY_PHONE: ['api', 'Recovery phone'], RECOVERY_CODES: ['api', 'Backup authentication codes'], RECOVERY_KEY: ['api', 'Account recovery key'], diff --git a/packages/fxa-auth-server/lib/routes/account.ts b/packages/fxa-auth-server/lib/routes/account.ts index 09fcf008d0d..5c854d569e4 100644 --- a/packages/fxa-auth-server/lib/routes/account.ts +++ b/packages/fxa-auth-server/lib/routes/account.ts @@ -1607,11 +1607,13 @@ export class AccountHandler { invalidDomain?: boolean; hasLinkedAccount?: boolean; hasPassword?: boolean; + passwordlessSupported?: boolean; } = { exists: false, invalidDomain: undefined, hasLinkedAccount: undefined, hasPassword: undefined, + passwordlessSupported: undefined, }; try { @@ -1623,6 +1625,8 @@ export class AccountHandler { result.exists = true; result.hasLinkedAccount = (account.linkedAccounts?.length || 0) > 0; result.hasPassword = account.verifierSetAt > 0; + // Passwordless is supported if account has no password set + result.passwordlessSupported = account.verifierSetAt === 0; } else { const exist = await this.db.accountExists(email); if (!exist) { @@ -1642,6 +1646,14 @@ export class AccountHandler { if (checkDomain) { result.invalidDomain = invalidDomain; } + // For non-existent accounts, check if passwordless is supported + // Either by matching the forced email regex (for testing) or if globally enabled + if (thirdPartyAuthStatus) { + const isPasswordlessForced = + this.config.passwordlessOtp.forcedEmailAddresses?.test(email); + result.passwordlessSupported = + isPasswordlessForced || this.config.passwordlessOtp.enabled; + } if (this.customs.v2Enabled()) { await this.customs.check(request, email, 'accountStatusCheckFailed'); } @@ -2305,12 +2317,7 @@ export class AccountHandler { const account = await this.db.account(uid); const email = account.primaryEmail?.email; - await this.customs.checkAuthenticated( - request, - uid, - email, - 'metricsOpt' - ); + await this.customs.checkAuthenticated(request, uid, email, 'metricsOpt'); await Account.setMetricsOpt(uid, state); await this.profileClient.deleteCache(uid); @@ -2361,63 +2368,88 @@ export class AccountHandler { const account = accountRecord.value; // Format emails - const formattedEmails = emails.status === 'fulfilled' - ? emails.value.map((email: { email: string; isPrimary: boolean; isVerified: boolean }) => ({ - email: email.email, - isPrimary: email.isPrimary, - verified: email.isVerified, - })) - : []; + const formattedEmails = + emails.status === 'fulfilled' + ? emails.value.map( + (email: { + email: string; + isPrimary: boolean; + isVerified: boolean; + }) => ({ + email: email.email, + isPrimary: email.isPrimary, + verified: email.isVerified, + }) + ) + : []; // Format linked accounts - const linkedAccounts = linkedAccountsResult.status === 'fulfilled' - ? linkedAccountsResult.value.map((la: { providerId: number; authAt: number; enabled: boolean }) => ({ - providerId: la.providerId, - authAt: la.authAt, - enabled: la.enabled, - })) - : []; + const linkedAccounts = + linkedAccountsResult.status === 'fulfilled' + ? linkedAccountsResult.value.map( + (la: { providerId: number; authAt: number; enabled: boolean }) => ({ + providerId: la.providerId, + authAt: la.authAt, + enabled: la.enabled, + }) + ) + : []; // Format TOTP status - const totp = totpResult.status === 'fulfilled' && totpResult.value - ? { exists: true, verified: !!totpResult.value.verified } - : { exists: false, verified: false }; + const totp = + totpResult.status === 'fulfilled' && totpResult.value + ? { exists: true, verified: !!totpResult.value.verified } + : { exists: false, verified: false }; // Format backup codes status - const backupCodes = backupCodesResult.status === 'fulfilled' - ? backupCodesResult.value - : { hasBackupCodes: false, count: 0 }; + const backupCodes = + backupCodesResult.status === 'fulfilled' + ? backupCodesResult.value + : { hasBackupCodes: false, count: 0 }; // Calculate estimated sync device count (for recovery key promo eligibility) - const devicesCount = devicesResult.status === 'fulfilled' ? devicesResult.value.length : 0; - const authorizedClients = authorizedClientsResult.status === 'fulfilled' ? authorizedClientsResult.value : []; + const devicesCount = + devicesResult.status === 'fulfilled' ? devicesResult.value.length : 0; + const authorizedClients = + authorizedClientsResult.status === 'fulfilled' + ? authorizedClientsResult.value + : []; const syncOAuthClientsCount = authorizedClients.filter( - (client: { scope?: string }) => client.scope && client.scope.includes(OAUTH_SCOPE_OLD_SYNC) + (client: { scope?: string }) => + client.scope && client.scope.includes(OAUTH_SCOPE_OLD_SYNC) ).length; - const estimatedSyncDeviceCount = Math.max(devicesCount, syncOAuthClientsCount); + const estimatedSyncDeviceCount = Math.max( + devicesCount, + syncOAuthClientsCount + ); // Format recovery key status - const recoveryKey = recoveryKeyResult.status === 'fulfilled' && recoveryKeyResult.value - ? { exists: true, estimatedSyncDeviceCount } - : { exists: false, estimatedSyncDeviceCount }; + const recoveryKey = + recoveryKeyResult.status === 'fulfilled' && recoveryKeyResult.value + ? { exists: true, estimatedSyncDeviceCount } + : { exists: false, estimatedSyncDeviceCount }; // Format recovery phone status - const recoveryPhoneData = recoveryPhoneResult.status === 'fulfilled' - ? recoveryPhoneResult.value - : { exists: false, phoneNumber: null }; + const recoveryPhoneData = + recoveryPhoneResult.status === 'fulfilled' + ? recoveryPhoneResult.value + : { exists: false, phoneNumber: null }; const recoveryPhone = { ...recoveryPhoneData, available: recoveryPhoneAvailable, }; // Format security events - const securityEvents = securityEventsResult.status === 'fulfilled' - ? securityEventsResult.value.map((e: { name: string; createdAt: number; verified?: boolean }) => ({ - name: e.name, - createdAt: e.createdAt, - verified: e.verified, - })) - : []; + const securityEvents = + securityEventsResult.status === 'fulfilled' + ? securityEventsResult.value.map( + (e: { name: string; createdAt: number; verified?: boolean }) => ({ + name: e.name, + createdAt: e.createdAt, + verified: e.verified, + }) + ) + : []; // Fetch subscriptions (separate block due to complexity) let webSubscriptions: Awaited = []; @@ -2802,6 +2834,7 @@ export const accountRoutes = ( hasLinkedAccount: isA.boolean().optional(), hasPassword: isA.boolean().optional(), invalidDomain: isA.boolean().optional(), + passwordlessSupported: isA.boolean().optional(), }), }, }, diff --git a/packages/fxa-auth-server/lib/routes/index.js b/packages/fxa-auth-server/lib/routes/index.js index 2299fc0c7e1..4e09ca18f82 100644 --- a/packages/fxa-auth-server/lib/routes/index.js +++ b/packages/fxa-auth-server/lib/routes/index.js @@ -241,6 +241,17 @@ module.exports = function ( const { mfaRoutes } = require('./mfa'); const mfa = mfaRoutes(customs, db, log, mailer, statsd, config); + const { passwordlessRoutes } = require('./passwordless'); + const passwordless = passwordlessRoutes( + log, + db, + config, + customs, + glean, + statsd, + authServerCacheRedis + ); + let basePath = url.parse(config.publicUrl).path; if (basePath === '/') { basePath = ''; @@ -253,6 +264,7 @@ module.exports = function ( attachedClients, emails, password, + passwordless, recoveryCodes, recoveryPhone, securityEvents, diff --git a/packages/fxa-auth-server/lib/routes/passwordless.ts b/packages/fxa-auth-server/lib/routes/passwordless.ts new file mode 100644 index 00000000000..7bf2ecc138c --- /dev/null +++ b/packages/fxa-auth-server/lib/routes/passwordless.ts @@ -0,0 +1,474 @@ +/* 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 { Redis } from 'ioredis'; +import * as isA from 'joi'; +import { StatsD } from 'hot-shots'; +import * as uuid from 'uuid'; +import Container from 'typedi'; + +import { OtpManager, OtpStorage } from '@fxa/shared/otp'; +import { AppError as error } from '@fxa/accounts/errors'; +import { + constructLocalTimeAndDateStrings, + splitEmails, +} from '@fxa/accounts/email-renderer'; + +import { ConfigType } from '../../config'; +import PASSWORDLESS_DOCS from '../../docs/swagger/passwordless-api'; +import DESCRIPTION from '../../docs/swagger/shared/descriptions'; +import * as random from '../crypto/random'; +import { schema as METRICS_CONTEXT_SCHEMA } from '../metrics/context'; +import { gleanMetrics } from '../metrics/glean'; +import { AuthLogger, AuthRequest } from '../types'; +import { recordSecurityEvent } from './utils/security-event'; +import * as validators from './validators'; +import { FxaMailer } from '../senders/fxa-mailer'; +import { formatUserAgentInfo } from 'fxa-shared/lib/user-agent'; +import { formatGeoData } from 'fxa-shared/lib/geo-data'; + +/** + * Redis adapter for OTP storage + */ +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); + } +} + +/** + * Handler class for passwordless authentication endpoints + */ +class PasswordlessHandler { + private otpManager: OtpManager; + private fxaMailer: FxaMailer; + private otpUtils: any; + + constructor( + private log: AuthLogger, + private db: any, + private config: ConfigType, + private customs: any, + private glean: ReturnType, + private statsd: StatsD, + authServerCacheRedis: Redis + ) { + const otpRedisAdapter = new OtpRedisAdapter( + authServerCacheRedis, + config.passwordlessOtp.ttl + ); + this.otpManager = new OtpManager( + { kind: 'passwordless', digits: config.passwordlessOtp.digits }, + otpRedisAdapter + ); + this.fxaMailer = Container.get(FxaMailer); + // For checking if account has TOTP enabled + this.otpUtils = require('./utils/otp').default(db, statsd); + } + + /** + * Check if an account is eligible for passwordless authentication + * An account is eligible if: + * - It doesn't exist (new registration) + * - The email matches the forcedEmailAddress config + * - It exists but has no password set (verifierSetAt === 0) + */ + private isPasswordlessEligible(account: any | null, email: string): boolean { + if (!account) { + return true; // New account + } + + const forced = + this.config.passwordlessOtp && + this.config.passwordlessOtp.forcedEmailAddresses; + if (forced && forced.test(email)) { + return true; + } + + // Existing account must not have a password set + return account.verifierSetAt === 0; + } + + /** + * Send OTP code for passwordless authentication + */ + async sendCode(request: AuthRequest) { + this.log.begin('Passwordless.sendCode', request); + + const { email } = request.payload as { email: string }; + + // Rate limiting + await this.customs.check(request, email, 'passwordlessSendOtp'); + + // Check if account exists and is eligible + let account: any = null; + let isNewAccount = true; + + try { + account = await this.db.accountRecord(email); + isNewAccount = false; + } catch (err: any) { + if (err.errno !== error.ERRNO.ACCOUNT_UNKNOWN) { + throw err; + } + // Account doesn't exist - this is a new registration + } + + // Check eligibility + if (!this.isPasswordlessEligible(account, email)) { + throw error.cannotCreatePassword(); // Account has a password, use standard flow + } + + // Generate OTP code + // For new accounts, use email as the key; for existing accounts, use uid + const otpKey = account ? account.uid : email; + const code = await this.otpManager.create(otpKey); + + // Send OTP email + const geoData = request.app.geo; + const { browser, os, osVersion } = request.app.ua; + const { deviceId, flowId, flowBeginTime } = + await request.app.metricsContext; + + const { time, date, acceptLanguage, timeZone } = + constructLocalTimeAndDateStrings( + request.app.acceptLanguage, + geoData.timeZone + ); + + // For MVP, we'll use the password forgot OTP email + // TODO: Create a dedicated passwordless OTP email template + const emailAddresses = account + ? splitEmails(account.emails) + : { to: email, cc: [] as string[] }; + + await this.fxaMailer.sendPasswordForgotOtpEmail({ + code, + to: emailAddresses.to, + cc: emailAddresses.cc, + deviceId, + flowId, + flowBeginTime, + time, + date, + acceptLanguage, + timeZone, + sync: false, + device: formatUserAgentInfo({ browser, os, osVersion }), + location: formatGeoData(geoData.location), + uid: '', + metricsEnabled: false, + }); + + this.statsd.increment('passwordless.sendCode.success'); + + // Record security event + await recordSecurityEvent('account.passwordless_login_otp_sent', { + db: this.db, + request, + account: account ? { uid: account.uid } : undefined, + }); + + return {}; + } + + /** + * Confirm OTP code and create session + */ + async confirmCode(request: AuthRequest) { + this.log.begin('Passwordless.confirmCode', request); + + const { email, code } = request.payload as { email: string; code: string }; + + // Rate limiting + await this.customs.check(request, email, 'passwordlessVerifyOtp'); + + // Daily limit + if (this.customs.v2Enabled()) { + await this.customs.check(request, email, 'passwordlessVerifyOtpPerDay'); + } + + // Check if account exists + let account: any = null; + let isNewAccount = true; + + try { + account = await this.db.accountRecord(email); + isNewAccount = false; + } catch (err: any) { + if (err.errno !== error.ERRNO.ACCOUNT_UNKNOWN) { + throw err; + } + } + + // Check eligibility + if (account && !this.isPasswordlessEligible(account, email)) { + throw error.cannotCreatePassword(); + } + + // Check if account has 2FA (TOTP) enabled + // Accounts with 2FA must use the password + TOTP flow for security + if (account) { + const hasTotpToken = await this.otpUtils.hasTotpToken(account); + if (hasTotpToken) { + this.log.info('passwordless.confirmCode.totpRequired', { + uid: account.uid, + }); + throw error.totpRequired(); + } + } + + // Verify OTP + const otpKey = account ? account.uid : email; + const isValidCode = await this.otpManager.isValid(otpKey, code); + + if (!isValidCode) { + this.statsd.increment('passwordless.confirmCode.invalid'); + await recordSecurityEvent('account.passwordless_login_otp_failed', { + db: this.db, + request, + account: account ? { uid: account.uid } : undefined, + }); + throw error.invalidVerificationCode(); + } + + // Delete OTP (single use) + await this.otpManager.delete(otpKey); + + // Create account if new + if (isNewAccount) { + account = await this.createPasswordlessAccount(email, request); + this.statsd.increment('passwordless.registration.success'); + + await recordSecurityEvent('account.passwordless_registration_complete', { + db: this.db, + request, + account: { uid: account.uid }, + }); + } + + // Create session token + const sessionToken = await this.createSessionToken(account, request); + + this.statsd.increment('passwordless.confirmCode.success'); + + await recordSecurityEvent('account.passwordless_login_otp_verified', { + db: this.db, + request, + account: { uid: account.uid }, + }); + + return { + uid: account.uid, + sessionToken: sessionToken.data, + verified: sessionToken.emailVerified && sessionToken.tokenVerified, + authAt: sessionToken.lastAuthAt(), + isNewAccount, + }; + } + + /** + * Resend OTP code + */ + async resendCode(request: AuthRequest) { + this.log.begin('Passwordless.resendCode', request); + + const { email } = request.payload as { email: string }; + + // Delete existing code first + let account: any = null; + try { + account = await this.db.accountRecord(email); + } catch (err: any) { + if (err.errno !== error.ERRNO.ACCOUNT_UNKNOWN) { + throw err; + } + } + + const otpKey = account ? account.uid : email; + await this.otpManager.delete(otpKey); + + // Send new code (uses same logic as sendCode) + return this.sendCode(request); + } + + /** + * Create a new passwordless account + */ + private async createPasswordlessAccount( + email: string, + request: AuthRequest + ): Promise { + const emailCode = await random.hex(16); + const authSalt = await random.hex(32); + const [kA, wrapWrapKb, wrapWrapKbVersion2] = await random.hex(32, 32, 32); + + const account = await this.db.createAccount({ + uid: uuid.v4({}, Buffer.alloc(16)).toString('hex'), + createdAt: Date.now(), + email, + emailCode, + emailVerified: true, // Verified via OTP + kA, + wrapWrapKb, + wrapWrapKbVersion2, + authSalt, + clientSalt: undefined, + verifierVersion: this.config.verifierVersion, + verifyHash: Buffer.alloc(32).toString('hex'), + verifyHashVersion2: Buffer.alloc(32).toString('hex'), + verifierSetAt: 0, // No password set + locale: request.app.acceptLanguage, + }); + + return account; + } + + /** + * Create a session token for the account + */ + private async createSessionToken( + account: any, + request: AuthRequest + ): Promise { + const sessionTokenOptions = { + uid: account.uid, + email: account.email, + emailCode: account.emailCode, + emailVerified: true, + verifierSetAt: account.verifierSetAt, + mustVerify: false, + tokenVerificationId: null, // Already verified via OTP + 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, + uaFormFactor: request.app.ua.formFactor, + }; + + return this.db.createSessionToken(sessionTokenOptions); + } +} + +/** + * Export routes factory function + */ +export function passwordlessRoutes( + log: AuthLogger, + db: any, + config: ConfigType, + customs: any, + glean: ReturnType, + statsd: StatsD, + authServerCacheRedis: Redis +) { + // Feature flag check + // Routes are available if either: + // 1. The feature is globally enabled + if (!config.passwordlessOtp.enabled) { + return []; + } + + const handler = new PasswordlessHandler( + log, + db, + config, + customs, + glean, + statsd, + authServerCacheRedis + ); + + return [ + { + method: 'POST', + path: '/account/passwordless/send_code', + options: { + ...PASSWORDLESS_DOCS.PASSWORDLESS_SEND_CODE_POST, + auth: false, + validate: { + payload: isA.object({ + email: validators.email().required().description(DESCRIPTION.email), + service: validators.service + .optional() + .description(DESCRIPTION.serviceRP), + metricsContext: METRICS_CONTEXT_SCHEMA, + }), + }, + response: { + schema: isA.object({}), + }, + }, + handler: (request: AuthRequest) => handler.sendCode(request), + }, + { + method: 'POST', + path: '/account/passwordless/confirm_code', + options: { + ...PASSWORDLESS_DOCS.PASSWORDLESS_CONFIRM_CODE_POST, + auth: false, + validate: { + payload: isA.object({ + email: validators.email().required().description(DESCRIPTION.email), + code: isA + .string() + .length(config.passwordlessOtp.digits) + .regex(validators.DIGITS) + .required() + .description('The OTP code sent to the user email'), + service: validators.service + .optional() + .description(DESCRIPTION.serviceRP), + metricsContext: METRICS_CONTEXT_SCHEMA, + }), + }, + response: { + schema: isA.object({ + uid: isA.string().required(), + sessionToken: isA.string().required(), + verified: isA.boolean().required(), + authAt: isA.number().required(), + isNewAccount: isA.boolean().required(), + }), + }, + }, + handler: (request: AuthRequest) => handler.confirmCode(request), + }, + { + method: 'POST', + path: '/account/passwordless/resend_code', + options: { + ...PASSWORDLESS_DOCS.PASSWORDLESS_RESEND_CODE_POST, + auth: false, + validate: { + payload: isA.object({ + email: validators.email().required().description(DESCRIPTION.email), + service: validators.service + .optional() + .description(DESCRIPTION.serviceRP), + metricsContext: METRICS_CONTEXT_SCHEMA, + }), + }, + response: { + schema: isA.object({}), + }, + }, + handler: (request: AuthRequest) => handler.resendCode(request), + }, + ]; +} diff --git a/packages/fxa-auth-server/test/local/routes/passwordless.js b/packages/fxa-auth-server/test/local/routes/passwordless.js new file mode 100644 index 00000000000..660602017e7 --- /dev/null +++ b/packages/fxa-auth-server/test/local/routes/passwordless.js @@ -0,0 +1,615 @@ +/* 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/. */ + +'use strict'; + +const sinon = require('sinon'); +const assert = { ...sinon.assert, ...require('chai').assert }; +const mocks = require('../../mocks'); +const getRoute = require('../../routes_helpers').getRoute; +const proxyquire = require('proxyquire'); +const uuid = require('uuid'); +const crypto = require('crypto'); +const { AppError: error } = require('@fxa/accounts/errors'); + +function hexString(bytes) { + return crypto.randomBytes(bytes).toString('hex'); +} + +const TEST_EMAIL = 'test@example.com'; +const FORCED_EMAIL = 'forcepasswordless@example.com'; + +let mockOtpManager; +let mockOtpManagerCreate; +let mockOtpManagerIsValid; +let mockOtpManagerDelete; + +const makeRoutes = function (options = {}, requireMocks = {}) { + const config = options.config || {}; + config.passwordlessOtp = config.passwordlessOtp || { + enabled: true, + ttl: 300, + digits: 6, + forcedEmailAddresses: /forcepasswordless@example.com/, + }; + config.verifierVersion = config.verifierVersion || 0; + + const log = options.log || mocks.mockLog(); + const db = options.db || mocks.mockDB(); + const customs = options.customs || { + check: () => Promise.resolve(true), + v2Enabled: () => true, + }; + const glean = options.glean || mocks.mockGlean(); + const statsd = options.statsd || mocks.mockStatsd(); + const redis = options.authServerCacheRedis || { + get: async () => null, + set: async () => 'OK', + del: async () => 0, + }; + + // Mock OtpManager + mockOtpManagerCreate = sinon.stub().resolves('123456'); + mockOtpManagerIsValid = sinon.stub().resolves(true); + mockOtpManagerDelete = sinon.stub().resolves(); + + mockOtpManager = { + create: mockOtpManagerCreate, + isValid: mockOtpManagerIsValid, + delete: mockOtpManagerDelete, + }; + + mocks.mockFxaMailer(); + + const { passwordlessRoutes } = proxyquire( + '../../../lib/routes/passwordless', + { + '@fxa/shared/otp': { + OtpManager: sinon.stub().returns(mockOtpManager), + }, + './utils/otp': { + default: () => ({ + hasTotpToken: options.hasTotpToken || sinon.stub().resolves(false), + }), + }, + './utils/security-event': { + recordSecurityEvent: + options.recordSecurityEvent || sinon.stub().resolves(), + }, + ...requireMocks, + } + ); + + return passwordlessRoutes(log, db, config, customs, glean, statsd, redis); +}; + +function runTest(route, request, assertions) { + return new Promise((resolve, reject) => { + try { + return route.handler(request).then(resolve, reject); + } catch (err) { + reject(err); + } + }).then(assertions); +} + +describe('/account/passwordless/send_code', () => { + let uid, mockLog, mockRequest, mockDB, mockCustoms, route, routes; + + beforeEach(() => { + uid = uuid.v4({}, Buffer.alloc(16)).toString('hex'); + mockLog = mocks.mockLog(); + mockDB = mocks.mockDB({ + uid, + email: TEST_EMAIL, + emailVerified: true, + verifierSetAt: 0, + }); + mockCustoms = { + check: sinon.spy(() => Promise.resolve()), + v2Enabled: () => true, + }; + mockRequest = mocks.mockRequest({ + log: mockLog, + payload: { + email: TEST_EMAIL, + metricsContext: { + deviceId: 'device123', + flowId: 'flow123', + flowBeginTime: Date.now(), + }, + }, + }); + + routes = makeRoutes({ + log: mockLog, + db: mockDB, + customs: mockCustoms, + }); + route = getRoute(routes, '/account/passwordless/send_code', 'POST'); + }); + + afterEach(() => { + mockOtpManagerCreate.resetHistory(); + mockOtpManagerIsValid.resetHistory(); + mockOtpManagerDelete.resetHistory(); + }); + + it('should send OTP for new account', () => { + mockDB.accountRecord = sinon.spy(() => + Promise.reject(error.unknownAccount()) + ); + + return runTest(route, mockRequest, (result) => { + assert.equal(mockCustoms.check.callCount, 1, 'customs.check was called'); + assert.equal( + mockCustoms.check.args[0][1], + TEST_EMAIL, + 'customs.check called with email' + ); + assert.equal( + mockCustoms.check.args[0][2], + 'passwordlessSendOtp', + 'customs.check called with correct action' + ); + + assert.equal( + mockOtpManagerCreate.callCount, + 1, + 'otpManager.create was called' + ); + assert.equal( + mockOtpManagerCreate.args[0][0], + TEST_EMAIL, + 'otpManager.create called with email for new account' + ); + + assert.deepEqual(result, {}, 'response is empty object'); + }); + }); + + it('should send OTP for existing passwordless account', () => { + mockDB.accountRecord = sinon.spy(() => + Promise.resolve({ + uid, + email: TEST_EMAIL, + verifierSetAt: 0, + emails: [{ email: TEST_EMAIL, isPrimary: true }], + }) + ); + + return runTest(route, mockRequest, (result) => { + assert.equal(mockDB.accountRecord.callCount, 1); + assert.equal(mockOtpManagerCreate.callCount, 1); + assert.equal( + mockOtpManagerCreate.args[0][0], + uid, + 'otpManager.create called with uid for existing account' + ); + assert.deepEqual(result, {}); + }); + }); + + it('should send OTP for forcedEmailAddress', () => { + mockDB.accountRecord = sinon.spy(() => + Promise.resolve({ + uid, + email: FORCED_EMAIL, + verifierSetAt: Date.now(), + emails: [{ email: FORCED_EMAIL, isPrimary: true }], + }) + ); + mockRequest = mocks.mockRequest({ + log: mockLog, + payload: { + email: FORCED_EMAIL, + metricsContext: { + deviceId: 'device123', + flowId: 'flow123', + flowBeginTime: Date.now(), + }, + }, + }); + + return runTest(route, mockRequest, (result) => { + assert.equal(mockDB.accountRecord.callCount, 1); + assert.equal(mockOtpManagerCreate.callCount, 1); + assert.equal( + mockOtpManagerCreate.args[0][0], + uid, + 'otpManager.create called with uid for existing account' + ); + assert.deepEqual(result, {}); + }); + }); + + it('should reject account with password', () => { + mockDB.accountRecord = sinon.spy(() => + Promise.resolve({ + uid, + email: TEST_EMAIL, + verifierSetAt: Date.now(), + }) + ); + + return runTest(route, mockRequest).then( + () => assert.fail('should have thrown'), + (err) => { + assert.equal(mockDB.accountRecord.callCount, 1); + assert.equal(mockOtpManagerCreate.callCount, 0); + assert.equal(err.errno, 206); + } + ); + }); + + it('should apply rate limiting', () => { + mockCustoms.check = sinon.spy(() => + Promise.reject(error.tooManyRequests()) + ); + + return runTest(route, mockRequest).then( + () => assert.fail('should have thrown'), + (err) => { + assert.equal(mockCustoms.check.callCount, 1); + assert.equal(err.errno, error.ERRNO.THROTTLED); + } + ); + }); +}); + +describe('/account/passwordless/confirm_code', () => { + let uid, mockLog, mockRequest, mockDB, mockCustoms, route, routes; + + beforeEach(() => { + uid = uuid.v4({}, Buffer.alloc(16)).toString('hex'); + mockLog = mocks.mockLog(); + mockDB = mocks.mockDB({ + uid, + email: TEST_EMAIL, + emailCode: hexString(16), + emailVerified: true, + verifierSetAt: 0, + }); + mockCustoms = { + check: sinon.spy(() => Promise.resolve()), + v2Enabled: () => true, + }; + mockRequest = mocks.mockRequest({ + log: mockLog, + payload: { + email: TEST_EMAIL, + code: '123456', + metricsContext: { + deviceId: 'device123', + flowId: 'flow123', + flowBeginTime: Date.now(), + }, + }, + }); + + routes = makeRoutes({ + log: mockLog, + db: mockDB, + customs: mockCustoms, + }); + route = getRoute(routes, '/account/passwordless/confirm_code', 'POST'); + }); + + afterEach(() => { + mockOtpManagerCreate.resetHistory(); + mockOtpManagerIsValid.resetHistory(); + mockOtpManagerDelete.resetHistory(); + }); + + it('should create new account and session for valid code', () => { + mockDB.accountRecord = sinon.spy(() => + Promise.reject(error.unknownAccount()) + ); + mockDB.createAccount = sinon.spy(() => + Promise.resolve({ + uid, + email: TEST_EMAIL, + emailCode: hexString(16), + verifierSetAt: 0, + }) + ); + mockDB.createSessionToken = sinon.spy(() => + Promise.resolve({ + data: 'sessiontoken123', + emailVerified: true, + tokenVerified: true, + lastAuthAt: () => 1234567890, + }) + ); + + return runTest(route, mockRequest, (result) => { + assert.equal( + mockCustoms.check.callCount, + 2, + 'customs.check called twice' + ); + assert.equal( + mockCustoms.check.args[0][2], + 'passwordlessVerifyOtp', + 'first check is for verify' + ); + assert.equal( + mockCustoms.check.args[1][2], + 'passwordlessVerifyOtpPerDay', + 'second check is for daily limit' + ); + + assert.equal(mockOtpManagerIsValid.callCount, 1); + assert.equal(mockOtpManagerIsValid.args[0][0], TEST_EMAIL); + assert.equal(mockOtpManagerIsValid.args[0][1], '123456'); + + assert.equal(mockOtpManagerDelete.callCount, 1); + assert.equal(mockOtpManagerDelete.args[0][0], TEST_EMAIL); + + assert.equal(mockDB.createAccount.callCount, 1); + const accountArgs = mockDB.createAccount.args[0][0]; + assert.equal(accountArgs.email, TEST_EMAIL); + assert.equal(accountArgs.emailVerified, true); + assert.equal(accountArgs.verifierSetAt, 0); + + assert.equal(mockDB.createSessionToken.callCount, 1); + + assert.equal(result.uid, uid); + assert.equal(result.sessionToken, 'sessiontoken123'); + assert.equal(result.verified, true); + assert.equal(result.authAt, 1234567890); + assert.equal(result.isNewAccount, true); + }); + }); + + it('should create session for existing account with valid code', () => { + mockDB.accountRecord = sinon.spy(() => + Promise.resolve({ + uid, + email: TEST_EMAIL, + emailCode: hexString(16), + verifierSetAt: 0, + }) + ); + mockDB.createSessionToken = sinon.spy(() => + Promise.resolve({ + data: 'sessiontoken123', + emailVerified: true, + tokenVerified: true, + lastAuthAt: () => 1234567890, + }) + ); + + return runTest(route, mockRequest, (result) => { + assert.equal(mockOtpManagerIsValid.callCount, 1); + assert.equal(mockOtpManagerIsValid.args[0][0], uid); + + assert.equal(mockDB.createAccount.callCount, 0); + assert.equal(mockDB.createSessionToken.callCount, 1); + + assert.equal(result.uid, uid); + assert.equal(result.isNewAccount, false); + }); + }); + + it('should reject invalid OTP code', () => { + mockDB.accountRecord = sinon.spy(() => + Promise.resolve({ + uid, + email: TEST_EMAIL, + verifierSetAt: 0, + }) + ); + mockOtpManagerIsValid.resolves(false); + + return runTest(route, mockRequest).then( + () => assert.fail('should have thrown'), + (err) => { + assert.equal(mockOtpManagerIsValid.callCount, 1); + assert.equal(mockOtpManagerDelete.callCount, 0); + assert.equal(err.errno, 105); + } + ); + }); + + it('should reject account with TOTP enabled', () => { + mockDB.accountRecord = sinon.spy(() => + Promise.resolve({ + uid, + email: TEST_EMAIL, + verifierSetAt: 0, + }) + ); + + const hasTotpToken = sinon.stub().resolves(true); + routes = makeRoutes({ + log: mockLog, + db: mockDB, + customs: mockCustoms, + hasTotpToken, + }); + route = getRoute(routes, '/account/passwordless/confirm_code', 'POST'); + + return runTest(route, mockRequest).then( + () => assert.fail('should have thrown'), + (err) => { + assert.equal(hasTotpToken.callCount, 1); + assert.equal(mockOtpManagerIsValid.callCount, 0); + assert.equal(err.errno, 160); + } + ); + }); + + it('should reject account with password set', () => { + mockDB.accountRecord = sinon.spy(() => + Promise.resolve({ + uid, + email: TEST_EMAIL, + verifierSetAt: Date.now(), + }) + ); + + return runTest(route, mockRequest).then( + () => assert.fail('should have thrown'), + (err) => { + assert.equal(mockDB.accountRecord.callCount, 1); + assert.equal(err.errno, 206); + } + ); + }); + + it('should include user agent info in session token', () => { + mockDB.accountRecord = sinon.spy(() => + Promise.resolve({ + uid, + email: TEST_EMAIL, + emailCode: hexString(16), + verifierSetAt: 0, + }) + ); + mockDB.createSessionToken = sinon.spy(() => + Promise.resolve({ + data: 'sessiontoken123', + emailVerified: true, + tokenVerified: true, + lastAuthAt: () => 1234567890, + }) + ); + mockRequest.app.ua = { + browser: 'Firefox', + browserVersion: '100', + os: 'Linux', + osVersion: '5.15', + deviceType: 'desktop', + formFactor: 'desktop', + }; + + return runTest(route, mockRequest, () => { + assert.equal(mockDB.createSessionToken.callCount, 1); + const sessionOpts = mockDB.createSessionToken.args[0][0]; + assert.equal(sessionOpts.uaBrowser, 'Firefox'); + assert.equal(sessionOpts.uaBrowserVersion, '100'); + assert.equal(sessionOpts.uaOS, 'Linux'); + assert.equal(sessionOpts.uaOSVersion, '5.15'); + assert.equal(sessionOpts.uaDeviceType, 'desktop'); + assert.equal(sessionOpts.uaFormFactor, 'desktop'); + assert.equal(sessionOpts.mustVerify, false); + assert.equal(sessionOpts.tokenVerificationId, null); + }); + }); +}); + +describe('/account/passwordless/resend_code', () => { + let uid, mockLog, mockRequest, mockDB, mockCustoms, route, routes; + + beforeEach(() => { + uid = uuid.v4({}, Buffer.alloc(16)).toString('hex'); + mockLog = mocks.mockLog(); + mockDB = mocks.mockDB({ + uid, + email: TEST_EMAIL, + emailVerified: true, + verifierSetAt: 0, + }); + mockCustoms = { + check: sinon.spy(() => Promise.resolve()), + v2Enabled: () => true, + }; + mockRequest = mocks.mockRequest({ + log: mockLog, + payload: { + email: TEST_EMAIL, + metricsContext: { + deviceId: 'device123', + flowId: 'flow123', + flowBeginTime: Date.now(), + }, + }, + }); + + routes = makeRoutes({ + log: mockLog, + db: mockDB, + customs: mockCustoms, + }); + route = getRoute(routes, '/account/passwordless/resend_code', 'POST'); + }); + + afterEach(() => { + mockOtpManagerCreate.resetHistory(); + mockOtpManagerDelete.resetHistory(); + }); + + it('should delete old code and send new one for new account', () => { + mockDB.accountRecord = sinon.spy(() => + Promise.reject(error.unknownAccount()) + ); + + return runTest(route, mockRequest, (result) => { + assert.equal(mockOtpManagerDelete.callCount, 1); + assert.equal(mockOtpManagerDelete.args[0][0], TEST_EMAIL); + + assert.equal(mockOtpManagerCreate.callCount, 1); + assert.equal(mockOtpManagerCreate.args[0][0], TEST_EMAIL); + + assert.deepEqual(result, {}); + }); + }); + + it('should delete old code and send new one for existing account', () => { + mockDB.accountRecord = sinon.spy(() => + Promise.resolve({ + uid, + email: TEST_EMAIL, + verifierSetAt: 0, + emails: [{ email: TEST_EMAIL, isPrimary: true }], + }) + ); + + return runTest(route, mockRequest, (result) => { + assert.equal(mockOtpManagerDelete.callCount, 1); + assert.equal(mockOtpManagerDelete.args[0][0], uid); + + assert.equal(mockOtpManagerCreate.callCount, 1); + assert.equal(mockOtpManagerCreate.args[0][0], uid); + + assert.deepEqual(result, {}); + }); + }); +}); + +describe('passwordless routes feature flags', () => { + it('should return empty array when feature disabled', () => { + const routes = makeRoutes({ + config: { + passwordlessOtp: { + enabled: false, + forcedEmailAddresses: /^$/, + }, + }, + }); + + assert.equal(routes.length, 0); + }); + + it('should return routes when feature enabled', () => { + const routes = makeRoutes({ + config: { + passwordlessOtp: { + enabled: true, + ttl: 300, + digits: 6, + }, + }, + }); + + assert.equal(routes.length, 3); + assert.equal(routes[0].path, '/account/passwordless/send_code'); + assert.equal(routes[0].method, 'POST'); + assert.equal(routes[1].path, '/account/passwordless/confirm_code'); + assert.equal(routes[1].method, 'POST'); + assert.equal(routes[2].path, '/account/passwordless/resend_code'); + assert.equal(routes[2].method, 'POST'); + }); +});