Merge pull request #17478 from mozilla/fxa-10320

feat(auth): Update 2FA via push backend api
This commit is contained in:
Vijay Budhram 2024-08-29 13:24:58 -04:00 коммит произвёл GitHub
Родитель 404806c749 309db81559
Коммит 433b004d74
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
4 изменённых файлов: 341 добавлений и 35 удалений

Просмотреть файл

@ -1796,6 +1796,38 @@ export default class AuthClient {
return this.sessionGet('/totp/exists', sessionToken, headers); return this.sessionGet('/totp/exists', sessionToken, headers);
} }
async sendLoginPushRequest(
sessionToken: hexstring,
headers?: Headers
): Promise<void> {
return this.sessionPost(
'/session/verify/send_push',
sessionToken,
{},
headers
);
}
async verifyLoginPushRequest(
email: string,
uid: string,
tokenVerificationId: string,
code: string,
headers?: Headers
): Promise<void> {
return await this.request(
'POST',
'/session/verify/verify_push',
{
email,
uid,
tokenVerificationId,
code,
},
headers
);
}
async verifyTotpCode( async verifyTotpCode(
sessionToken: hexstring, sessionToken: hexstring,
code: string, code: string,

Просмотреть файл

@ -99,10 +99,28 @@ const SESSION_RESEND_CODE_POST = {
notes: ['🔒 Authenticated with session token'], notes: ['🔒 Authenticated with session token'],
}; };
const SESSION_VERIFY_SEND_PUSH_POST = { const SESSION_SEND_PUSH_POST = {
...TAGS_SESSION, ...TAGS_SESSION,
description: '/session/verify/send_push', 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 = { const API_DOCS = {
@ -112,7 +130,8 @@ const API_DOCS = {
SESSION_STATUS_GET, SESSION_STATUS_GET,
SESSION_RESEND_CODE_POST, SESSION_RESEND_CODE_POST,
SESSION_VERIFY_CODE_POST, SESSION_VERIFY_CODE_POST,
SESSION_VERIFY_SEND_PUSH_POST, SESSION_SEND_PUSH_POST,
SESSION_VERIFY_PUSH_POST,
}; };
export default API_DOCS; export default API_DOCS;

Просмотреть файл

@ -475,22 +475,19 @@ module.exports = function (
method: 'POST', method: 'POST',
path: '/session/verify/send_push', path: '/session/verify/send_push',
options: { options: {
...SESSION_DOCS.SESSION_VERIFY_SEND_PUSH_POST, ...SESSION_DOCS.SESSION_SEND_PUSH_POST,
auth: { auth: {
strategy: 'sessionToken', strategy: 'sessionToken',
}, },
}, },
handler: async function (request) { handler: async function (request) {
log.begin('Session.verify.send_push', request); log.begin('Session.send_push', request);
const sessionToken = request.auth.credentials; const sessionToken = request.auth.credentials;
const { uid, tokenVerificationId } = sessionToken; const { uid, email, tokenVerificationId } = sessionToken;
const allDevices = await db.devices(uid);
const location = request.app.geo.location || {};
// Check to see if this account has a verified TOTP token. If so, then it should // 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 { try {
const result = await db.totpToken(sessionToken.uid); const result = await db.totpToken(sessionToken.uid);
@ -503,25 +500,28 @@ module.exports = function (
} }
} }
const { ua } = request.app; const allDevices = await db.devices(uid);
const uaInfo = {
uaBrowser: ua.browser,
uaOS: ua.os,
uaDeviceType: ua.deviceType,
};
// 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) => { 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 = `${ const confirmUrl = `${config.contentServer.url}/signin_push_code_confirm`;
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 localizer = new Localizer(new NodeRendererBindings()); const localizer = new Localizer(new NodeRendererBindings());
@ -548,18 +548,99 @@ module.exports = function (
const options = { const options = {
title: localizedStrings[titleFtlId], title: localizedStrings[titleFtlId],
body: localizedStrings[bodyFtlId], 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 { try {
await push.notifyVerifyLoginRequest(uid, filteredDevices, options); await push.notifyVerifyLoginRequest(uid, filteredDevices, {
...options,
url,
});
} catch (err) { } catch (err) {
log.error('Session.verify.send_push', { log.error('Session.send_push', {
uid: uid, uid: uid,
error: err, 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 {}; return {};
}, },
}, },

Просмотреть файл

@ -24,6 +24,36 @@ const signupCodeAccount = {
tokenVerificationId: 'sometoken', 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 = {}) { function makeRoutes(options = {}) {
const config = options.config || {}; const config = options.config || {};
config.oauth = config.oauth || {}; config.oauth = config.oauth || {};
@ -1289,11 +1319,14 @@ describe('/session/verify/send_push', () => {
let route, request, log, db, mailer, push; let route, request, log, db, mailer, push;
beforeEach(() => { beforeEach(() => {
db = mocks.mockDB({ ...signupCodeAccount }); db = mocks.mockDB({ ...signupCodeAccount, devices: MOCK_DEVICES });
db.totpToken = sinon.spy(() => Promise.resolve({ enabled: false }));
log = mocks.mockLog(); log = mocks.mockLog();
mailer = mocks.mockMailer(); mailer = mocks.mockMailer();
push = mocks.mockPush(); push = mocks.mockPush();
const config = {}; const config = {
contentServer: { url: 'http://localhost:3030' },
};
const routes = makeRoutes({ log, config, db, mailer, push }); const routes = makeRoutes({ log, config, db, mailer, push });
route = getRoute(routes, '/session/verify/send_push'); route = getRoute(routes, '/session/verify/send_push');
@ -1312,15 +1345,156 @@ describe('/session/verify/send_push', () => {
const response = await runTest(route, request); const response = await runTest(route, request);
assert.deepEqual(response, {}); assert.deepEqual(response, {});
assert.calledOnce(db.devices); assert.calledOnce(db.devices);
assert.calledOnce(push.notifyVerifyLoginRequest); assert.calledOnce(db.totpToken);
assert.calledOnce(db.account);
const args = push.notifyVerifyLoginRequest.args[0]; const args = push.notifyVerifyLoginRequest.args[0];
assert.equal(args[0], 'foo'); 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].title, 'Logging in to your Mozilla account?');
assert.equal(args[2].body, 'Click here to confirm its you'); assert.equal(args[2].body, 'Click here to confirm its you');
assert.include(args[2].url, 'sometoken'); const url = args[2].url;
assert.include(args[2].url, 'California'); assert.include(url, 'http://localhost:3030/signin_push_code_confirm?');
assert.include(args[2].url, encodeURIComponent('63.245.221.32')); 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');
}
}); });
}); });