зеркало из https://github.com/mozilla/fxa.git
Merge pull request #17478 from mozilla/fxa-10320
feat(auth): Update 2FA via push backend api
This commit is contained in:
Коммит
433b004d74
|
@ -1796,6 +1796,38 @@ export default class AuthClient {
|
|||
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(
|
||||
sessionToken: hexstring,
|
||||
code: string,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 {};
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
Загрузка…
Ссылка в новой задаче