From 309db81559fd286650ae7b57764f404456b1b911 Mon Sep 17 00:00:00 2001 From: Vijay Budhram Date: Tue, 27 Aug 2024 10:23:30 -0400 Subject: [PATCH] feat(auth): Update 2FA via push backend api --- packages/fxa-auth-client/lib/client.ts | 32 +++ .../docs/swagger/session-api.ts | 25 ++- .../fxa-auth-server/lib/routes/session.js | 131 +++++++++--- .../test/local/routes/session.js | 188 +++++++++++++++++- 4 files changed, 341 insertions(+), 35 deletions(-) diff --git a/packages/fxa-auth-client/lib/client.ts b/packages/fxa-auth-client/lib/client.ts index e9119e0c4e..afb197dd70 100644 --- a/packages/fxa-auth-client/lib/client.ts +++ b/packages/fxa-auth-client/lib/client.ts @@ -1796,6 +1796,38 @@ export default class AuthClient { return this.sessionGet('/totp/exists', sessionToken, headers); } + async sendLoginPushRequest( + sessionToken: hexstring, + headers?: Headers + ): Promise { + return this.sessionPost( + '/session/verify/send_push', + sessionToken, + {}, + headers + ); + } + + async verifyLoginPushRequest( + email: string, + uid: string, + tokenVerificationId: string, + code: string, + headers?: Headers + ): Promise { + return await this.request( + 'POST', + '/session/verify/verify_push', + { + email, + uid, + tokenVerificationId, + code, + }, + headers + ); + } + async verifyTotpCode( sessionToken: hexstring, code: string, diff --git a/packages/fxa-auth-server/docs/swagger/session-api.ts b/packages/fxa-auth-server/docs/swagger/session-api.ts index deafc85a41..ea1808a52a 100644 --- a/packages/fxa-auth-server/docs/swagger/session-api.ts +++ b/packages/fxa-auth-server/docs/swagger/session-api.ts @@ -99,10 +99,28 @@ const SESSION_RESEND_CODE_POST = { notes: ['πŸ”’ Authenticated with session token'], }; -const SESSION_VERIFY_SEND_PUSH_POST = { +const SESSION_SEND_PUSH_POST = { ...TAGS_SESSION, description: '/session/verify/send_push', - notes: ['πŸ”’ Authenticated with session token'], + notes: [ + dedent` + πŸ”’ Authenticated with session token + + Sends a push notification to all push enabled devices to verify current session. + `, + ], +}; + +const SESSION_VERIFY_PUSH_POST = { + ...TAGS_SESSION, + description: '/session/verify/verify_push', + notes: [ + dedent` + πŸ”’ Authenticated with session token + + Endpoint that accepts a code and tokenVerificationId to verify a session. + `, + ], }; const API_DOCS = { @@ -112,7 +130,8 @@ const API_DOCS = { SESSION_STATUS_GET, SESSION_RESEND_CODE_POST, SESSION_VERIFY_CODE_POST, - SESSION_VERIFY_SEND_PUSH_POST, + SESSION_SEND_PUSH_POST, + SESSION_VERIFY_PUSH_POST, }; export default API_DOCS; diff --git a/packages/fxa-auth-server/lib/routes/session.js b/packages/fxa-auth-server/lib/routes/session.js index 70025a9130..6835868f4e 100644 --- a/packages/fxa-auth-server/lib/routes/session.js +++ b/packages/fxa-auth-server/lib/routes/session.js @@ -475,22 +475,19 @@ module.exports = function ( method: 'POST', path: '/session/verify/send_push', options: { - ...SESSION_DOCS.SESSION_VERIFY_SEND_PUSH_POST, + ...SESSION_DOCS.SESSION_SEND_PUSH_POST, auth: { strategy: 'sessionToken', }, }, handler: async function (request) { - log.begin('Session.verify.send_push', request); + log.begin('Session.send_push', request); const sessionToken = request.auth.credentials; - const { uid, tokenVerificationId } = sessionToken; - - const allDevices = await db.devices(uid); - const location = request.app.geo.location || {}; + const { uid, email, tokenVerificationId } = sessionToken; // Check to see if this account has a verified TOTP token. If so, then it should - // not be allowed to bypass TOTP requirement by sending a sign-in confirmation email. + // not be allowed to bypass TOTP requirement by sending a sign-in push notification. try { const result = await db.totpToken(sessionToken.uid); @@ -503,25 +500,28 @@ module.exports = function ( } } - const { ua } = request.app; - const uaInfo = { - uaBrowser: ua.browser, - uaOS: ua.os, - uaDeviceType: ua.deviceType, - }; + const allDevices = await db.devices(uid); - // Don't send notification to current device + const account = await db.account(sessionToken.uid); + const secret = account.primaryEmail.emailCode; + + const code = otpUtils.generateOtpCode(secret, otpOptions); + + // Filter devices that can accept the push notification. const filteredDevices = allDevices.filter((d) => { - return d.sessionTokenId !== sessionToken.id; + // Don't push to the current device + if (d.sessionTokenId === sessionToken.id) { + return false; + } + // Exclude expired devices + if (d.pushEndpointExpired === true) { + return false; + } + // Currently, we only support sending push notifications to Firefox Desktop + return d.type === 'desktop' && d.uaBrowser === 'Firefox'; }); - const url = `${ - config.smtp.pushVerificationUrl - }?type=push_login_verification&code=${tokenVerificationId}&ua=${encodeURIComponent( - JSON.stringify(uaInfo) - )}&location=${encodeURIComponent( - JSON.stringify(location) - )}&ip=${encodeURIComponent(request.app.clientAddress)}`; + const confirmUrl = `${config.contentServer.url}/signin_push_code_confirm`; const localizer = new Localizer(new NodeRendererBindings()); @@ -548,18 +548,99 @@ module.exports = function ( const options = { title: localizedStrings[titleFtlId], body: localizedStrings[bodyFtlId], - url, }; + const { region, city, country } = request.app.geo; + const remoteMetaData = { + deviceName: sessionToken.deviceName, + deviceFamily: sessionToken.uaBrowser, + deviceOS: sessionToken.uaOS, + ipAddress: request.app.clientAddress, + city, + region, + country, + }; + const params = new URLSearchParams({ + tokenVerificationId, + code, + uid, + email, + remoteMetaData: encodeURIComponent(JSON.stringify(remoteMetaData)), + }); + const url = `${confirmUrl}?${params.toString()}`; try { - await push.notifyVerifyLoginRequest(uid, filteredDevices, options); + await push.notifyVerifyLoginRequest(uid, filteredDevices, { + ...options, + url, + }); } catch (err) { - log.error('Session.verify.send_push', { + log.error('Session.send_push', { uid: uid, error: err, }); } + return {}; + }, + }, + { + method: 'POST', + path: '/session/verify/verify_push', + options: { + ...SESSION_DOCS.SESSION_VERIFY_CODE_POST, + auth: { + strategy: 'sessionToken', + }, + validate: { + payload: isA.object({ + code: validators.DIGITS, + tokenVerificationId: validators.hexString.length(32), + }), + }, + }, + handler: async function (request) { + log.begin('Session.verify_push', request); + const options = request.payload; + const sessionToken = request.auth.credentials; + const { uid, email } = sessionToken; + const { code, tokenVerificationId } = options; + + await customs.check(request, email, 'verifySessionCode'); + request.emitMetricsEvent('session.verify_push'); + + const device = await db.deviceFromTokenVerificationId( + uid, + tokenVerificationId + ); + + // If device is not found, this means the device has already been verified. + // Since the user can not take any additional action, it is safe to return + // a successful response. + if (!device) { + return {}; + } + + // Check to see if the otp code passed matches the expected value from + // using the account's' `emailCode` as the secret in the otp code generation. + const account = await db.account(uid); + const secret = account.primaryEmail.emailCode; + + const isValidCode = otpUtils.verifyOtpCode(code, secret, otpOptions); + + if (!isValidCode) { + throw error.invalidOrExpiredOtpCode(); + } + + await db.verifyTokens(tokenVerificationId, account); + + // We have a matching code! Let's verify session and send the + // corresponding email and emit metrics. + request.emitMetricsEvent('account.confirmed', { uid }); + glean.login.verifyCodeConfirmed(request, { uid }); + await signinUtils.cleanupReminders({ verified: true }, account); + const devices = await db.devices(uid); + await push.notifyAccountUpdated(uid, devices, 'accountConfirm'); + return {}; }, }, diff --git a/packages/fxa-auth-server/test/local/routes/session.js b/packages/fxa-auth-server/test/local/routes/session.js index 7b2980ef7d..ab5662feca 100644 --- a/packages/fxa-auth-server/test/local/routes/session.js +++ b/packages/fxa-auth-server/test/local/routes/session.js @@ -24,6 +24,36 @@ const signupCodeAccount = { tokenVerificationId: 'sometoken', }; +const MOCK_DEVICES = [ + // Current device + { + sessionTokenId: 'sessionTokenId', + name: 'foo', + type: 'desktop', + pushEndpointExpired: false, + pushPublicKey: 'foo', + uaBrowser: 'Firefox', + }, + // Only pushable device + { + sessionTokenId: 'sessionTokenId2', + name: 'foo2', + type: 'desktop', + pushEndpointExpired: false, + pushPublicKey: 'foo', + uaBrowser: 'Firefox', + }, + // Unsupported mobile device + { + sessionTokenId: 'sessionTokenId3', + name: 'foo3', + type: 'mobile', + pushEndpointExpired: false, + pushPublicKey: 'foo', + uaBrowser: 'Firefox', + }, +]; + function makeRoutes(options = {}) { const config = options.config || {}; config.oauth = config.oauth || {}; @@ -1289,11 +1319,14 @@ describe('/session/verify/send_push', () => { let route, request, log, db, mailer, push; beforeEach(() => { - db = mocks.mockDB({ ...signupCodeAccount }); + db = mocks.mockDB({ ...signupCodeAccount, devices: MOCK_DEVICES }); + db.totpToken = sinon.spy(() => Promise.resolve({ enabled: false })); log = mocks.mockLog(); mailer = mocks.mockMailer(); push = mocks.mockPush(); - const config = {}; + const config = { + contentServer: { url: 'http://localhost:3030' }, + }; const routes = makeRoutes({ log, config, db, mailer, push }); route = getRoute(routes, '/session/verify/send_push'); @@ -1312,15 +1345,156 @@ describe('/session/verify/send_push', () => { const response = await runTest(route, request); assert.deepEqual(response, {}); assert.calledOnce(db.devices); - assert.calledOnce(push.notifyVerifyLoginRequest); + assert.calledOnce(db.totpToken); + assert.calledOnce(db.account); const args = push.notifyVerifyLoginRequest.args[0]; assert.equal(args[0], 'foo'); - assert.deepEqual(args[1], []); + assert.deepEqual(args[1], [ + { + sessionTokenId: 'sessionTokenId2', + name: 'foo2', + type: 'desktop', + pushEndpointExpired: false, + pushPublicKey: 'foo', + uaBrowser: 'Firefox', + }, + ]); assert.equal(args[2].title, 'Logging in to your Mozilla account?'); assert.equal(args[2].body, 'Click here to confirm it’s you'); - assert.include(args[2].url, 'sometoken'); - assert.include(args[2].url, 'California'); - assert.include(args[2].url, encodeURIComponent('63.245.221.32')); + const url = args[2].url; + assert.include(url, 'http://localhost:3030/signin_push_code_confirm?'); + assert.include(url, 'tokenVerificationId=sometoken'); + assert.match(url, /code=\d{6}/); + assert.include(url, 'uid=foo'); + assert.include(url, 'email=foo%40example.org'); + assert.include( + url, + 'remoteMetaData=%257B%2522deviceFamily%2522%253A%2522Firefox%2522%252C%2522ipAddress%2522%253A%252263.245.221.32%2522%257D' + ); + }); + + it('should not send a push notification if TOTP token is verified and enabled', async () => { + db.totpToken = sinon.spy(() => + Promise.resolve({ verified: true, enabled: true }) + ); + const response = await runTest(route, request); + assert.deepEqual(response, {}); + assert.calledOnce(db.totpToken); + assert.notCalled(push.notifyVerifyLoginRequest); + }); +}); + +describe('/session/verify/verify_push', () => { + let route, request, log, db, mailer, push, customs; + + beforeEach(() => { + db = mocks.mockDB({ ...signupCodeAccount, devices: MOCK_DEVICES }); + db.deviceFromTokenVerificationId = sinon.spy(() => + Promise.resolve(MOCK_DEVICES[1]) + ); + log = mocks.mockLog(); + mailer = mocks.mockMailer(); + push = mocks.mockPush(); + customs = mocks.mockCustoms(); + const config = {}; + const routes = makeRoutes({ log, config, db, mailer, push, customs }); + route = getRoute(routes, '/session/verify/verify_push'); + }); + + it('should verify push notification login request', async () => { + const expectedCode = getExpectedOtpCode({}, signupCodeAccount.emailCode); + request = mocks.mockRequest({ + log, + credentials: { + ...signupCodeAccount, + uaBrowser: 'Firefox', + id: 'sessionTokenId', + }, + payload: { + code: expectedCode, + uid: 'foo', + email: 'a@aa.com', + tokenVerificationId: 'sometoken', + }, + }); + const response = await runTest(route, request); + assert.deepEqual(response, {}); + + assert.calledOnceWithExactly( + customs.check, + request, + 'foo@example.org', + 'verifySessionCode' + ); + assert.calledOnceWithExactly(db.devices, 'foo'); + assert.calledOnceWithExactly( + db.deviceFromTokenVerificationId, + 'foo', + 'sometoken' + ); + assert.calledOnceWithExactly(db.account, 'foo'); + assert.calledOnceWithMatch(db.verifyTokens, 'sometoken'); + + assert.calledOnceWithExactly( + push.notifyAccountUpdated, + 'foo', + MOCK_DEVICES, + 'accountConfirm' + ); + }); + + it('should return if session is already verified', async () => { + db.deviceFromTokenVerificationId = sinon.spy(() => + Promise.resolve(undefined) + ); + request = mocks.mockRequest({ + log, + credentials: { + ...signupCodeAccount, + uaBrowser: 'Firefox', + id: 'sessionTokenId', + }, + payload: { + code: '123123', + uid: 'foo', + email: 'foo@example.org', + tokenVerificationId: 'sometoken', + }, + }); + const response = await runTest(route, request); + assert.deepEqual(response, {}); + assert.notCalled(db.verifyTokens); + }); + + it('should fail if invalid code', async () => { + request = mocks.mockRequest({ + log, + credentials: { + ...signupCodeAccount, + uaBrowser: 'Firefox', + id: 'sessionTokenId', + }, + payload: { + code: '123123', + uid: 'foo', + email: 'foo@example.org', + tokenVerificationId: 'sometoken', + }, + }); + try { + await runTest(route, request); + assert.fail('should have thrown'); + } catch (err) { + assert.calledOnceWithExactly( + customs.check, + request, + 'foo@example.org', + 'verifySessionCode' + ); + + assert.deepEqual(err.errno, 183); + assert.deepEqual(err.message, 'Invalid or expired confirmation code'); + } }); });