зеркало из 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);
|
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 it’s you');
|
assert.equal(args[2].body, 'Click here to confirm it’s 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');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
Загрузка…
Ссылка в новой задаче