Merge pull request #2985 from mozilla/fenix-token-exchanges

Notify push and email on code exchanges
This commit is contained in:
Vlad Filippov 2019-04-01 09:41:33 -04:00 коммит произвёл GitHub
Родитель 158e1add5e 2e25c45669
Коммит 2e1b01f87b
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
10 изменённых файлов: 268 добавлений и 12 удалений

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

@ -114,7 +114,7 @@ module.exports = (log, db, push) => {
deviceName = synthesizeName(deviceInfo);
}
if (credentials.tokenVerified) {
request.app.devices.then(devices => {
db.devices(credentials.uid).then(devices => {
const otherDevices = devices.filter(device => device.id !== result.id);
return push.notifyDeviceConnected(credentials.uid, otherDevices, deviceName);
});

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

@ -0,0 +1,26 @@
/* 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 Joi = require('joi');
const validators = require('../routes/validators');
module.exports = (config) => {
return {
path: '/v1/verify',
method: 'POST',
validate: {
payload: {
token: validators.accessToken.required(),
},
response: {
user: Joi.string().required(),
client_id: Joi.string().required(),
scope: Joi.array(),
profile_changed_at: Joi.number().min(0)
}
}
};
};

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

@ -32,6 +32,7 @@ module.exports = (log, config) => {
grantTokensFromAuthorizationCode: require('./grant-tokens-from-authorization-code')(config),
grantTokensFromRefreshToken: require('./grant-tokens-from-refresh-token')(config),
grantTokensFromCredentials: require('./grant-tokens-from-credentials')(config),
checkAccessToken: require('./check-access-token')(config),
});
const api = new OAuthAPI(config.oauth.url, config.oauth.poolee);
@ -115,6 +116,14 @@ module.exports = (log, config) => {
}
},
async checkAccessToken(token) {
try {
return await api.checkAccessToken(token);
} catch (err) {
throw mapOAuthError(log, err);
}
}
/* As we work through the process of merging oauth-server
* into auth-server, future methods we might want to include
* here will be things like the following:
@ -122,9 +131,6 @@ module.exports = (log, config) => {
async getClientInstances(account) {
},
async checkAccessToken(token) {
}
async revokeAccessToken(token) {
}

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

@ -38,7 +38,7 @@ module.exports = function (
push,
verificationReminders,
);
const oauth = require('./oauth')(log, config, oauthdb);
const oauth = require('./oauth')(log, config, oauthdb, db, mailer, devicesImpl);
const devicesSessions = require('./devices-and-sessions')(log, db, config, customs, push, pushbox, devicesImpl, oauthdb);
const emails = require('./emails')(log, db, mailer, config, customs, push, verificationReminders);
const password = require('./password')(

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

@ -18,8 +18,9 @@
const Joi = require('joi');
const error = require('../error');
const oauthRouteUtils = require('./utils/oauth');
module.exports = (log, config, oauthdb) => {
module.exports = (log, config, oauthdb, db, mailer, devices) => {
const routes = [
{
method: 'GET',
@ -110,19 +111,31 @@ module.exports = (log, config, oauthdb) => {
},
handler: async function (request) {
const sessionToken = request.auth.credentials;
let grant;
switch (request.payload.grant_type) {
case 'authorization_code':
return await oauthdb.grantTokensFromAuthorizationCode(request.payload);
grant = await oauthdb.grantTokensFromAuthorizationCode(request.payload);
break;
case 'refresh_token':
return await oauthdb.grantTokensFromRefreshToken(request.payload);
grant = await oauthdb.grantTokensFromRefreshToken(request.payload);
break;
case 'fxa-credentials':
if (! sessionToken) {
throw error.invalidToken();
}
return await oauthdb.grantTokensFromSessionToken(sessionToken, request.payload);
grant = await oauthdb.grantTokensFromSessionToken(sessionToken, request.payload);
break;
default:
throw error.internalValidationError();
}
if (grant.refresh_token) {
// if a refresh token has been provisioned as part of the flow
// then we want to send some notifications to the user
await oauthRouteUtils.newTokenNotification(db, oauthdb, mailer, devices, request, grant);
}
return grant;
}
},
];

62
lib/routes/utils/oauth.js Normal file
Просмотреть файл

@ -0,0 +1,62 @@
/* 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 encrypt = require('../../../fxa-oauth-server/lib/encrypt');
const ScopeSet = require('fxa-shared').oauth.scopes;
// right now we only care about notifications for the following scopes
// if not a match, then we don't notify
const NOTIFICATION_SCOPES = ScopeSet.fromArray(['https://identity.mozilla.com/apps/oldsync']);
module.exports = {
newTokenNotification: async function newTokenNotification (db, oauthdb, mailer, devices, request, grant) {
const clientId = request.payload.client_id;
const scopeSet = ScopeSet.fromString(grant.scope);
const credentials = request.auth && request.auth.credentials || {};
if (! scopeSet.intersects(NOTIFICATION_SCOPES)) {
// right now we only care about notifications for the `oldsync` scope
// if not a match, then we don't do any notifications
return;
}
if (! credentials.uid) {
// this can be removed once issue #3000 has been resolved
const tokenVerify = await oauthdb.checkAccessToken({
token: grant.access_token
});
// some grant flows won't have the uid in `credentials`
credentials.uid = tokenVerify.user;
}
if (! credentials.refreshTokenId) {
// provide a refreshToken for the device creation below
credentials.refreshTokenId = encrypt.hash(grant.refresh_token).toString('hex');
}
// we set tokenVerified because the granted scope is part of NOTIFICATION_SCOPES
credentials.tokenVerified = true;
credentials.client = await oauthdb.getClientInfo(clientId);
// The following upsert gets no `deviceInfo`.
// However, `credentials.client` lets it generate a default name for the device.
await devices.upsert(request, credentials, {});
const geoData = request.app.geo;
const ip = request.app.clientAddress;
const emailOptions = {
acceptLanguage: request.app.acceptLanguage,
ip,
location: geoData.location,
service: clientId,
timeZone: geoData.timeZone,
uid: credentials.uid
};
const account = await db.account(credentials.uid);
await mailer.sendNewDeviceLoginNotification(account.emails, account, emailOptions);
}
};

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

@ -22,8 +22,10 @@ const mockConfig = {
domain: 'accounts.example.com'
};
const MOCK_UID = 'ABCDEF';
const MOCK_UID = '1a147912d8de4ab5842ecc9fb7186800';
const MOCK_CLIENT_ID = '0123456789ABCDEF';
const MOCK_SCOPES = 'mock-scope another-scope';
const MOCK_TOKEN = '8ddd955475561c723d38863defc558788aee362c4f28df76b997ae62646a7b43';
const MOCK_CLIENT_INFO = {
id: MOCK_CLIENT_ID,
name: 'mock client',
@ -133,7 +135,6 @@ describe('oauthdb', () => {
describe('getScopedKeyData', () => {
const ZEROS = Buffer.alloc(32).toString('hex');
const MOCK_SCOPES = 'mock-scope another-scope';
const MOCK_CREDENTIALS = {
uid: MOCK_UID,
verifierSetAt: 12345,
@ -301,4 +302,23 @@ describe('oauthdb', () => {
});
describe('checkAccessToken', () => {
it('works', async () => {
const verifyResponse = {
user: MOCK_UID,
client_id: MOCK_CLIENT_ID,
scope: ['https://identity.mozilla.com/apps/oldsync', 'openid']
};
mockOAuthServer.post('/v1/verify', body => true)
.reply(200, verifyResponse);
oauthdb = oauthdbModule(mockLog(), mockConfig);
const response = await oauthdb.checkAccessToken({
token: MOCK_TOKEN
});
assert.deepEqual(verifyResponse, response);
});
});
});

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

@ -0,0 +1,100 @@
/* 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 TEST_EMAIL = 'foo@gmail.com';
const MOCK_UID = '23d4847823f24b0f95e1524987cb0391';
const MOCK_REFRESH_TOKEN = '40f61392cf69b0be709fbd3122d0726bb32247b476b2a28451345e7a5555cec7';
const MOCK_REFRESH_TOKEN_2 = '00661392cf69b0be709fbd3122d0726bb32247b476b2a28451345e7a5555cec7';
const MOCK_REFRESH_TOKEN_ID_2 = '0e4f2255bed0ae53af401150488e69f22beae103b7d6857a5194df00c9827d19';
const OAUTH_CLIENT_ID = '3c49430b43dfba77';
const MOCK_CHECK_RESPONSE = {
user: MOCK_UID,
client_id: OAUTH_CLIENT_ID,
scope: ['https://identity.mozilla.com/apps/oldsync', 'openid']
};
describe('newTokenNotification', () => {
let db;
let oauthdb;
let mailer;
let devices;
let request;
let credentials;
let grant;
const oauthUtils = require('../../../../lib/routes/utils/oauth');
beforeEach(() => {
db = mocks.mockDB({
email: TEST_EMAIL,
emailVerified: true,
uid: MOCK_UID
});
oauthdb = mocks.mockOAuthDB({
checkAccessToken: sinon.spy(async () => {
return MOCK_CHECK_RESPONSE;
})
});
mailer = mocks.mockMailer();
devices = mocks.mockDevices();
credentials = {
uid: MOCK_UID,
refreshTokenId: MOCK_REFRESH_TOKEN
};
request = mocks.mockRequest({credentials});
grant = {
scope: 'profile https://identity.mozilla.com/apps/oldsync',
refresh_token: MOCK_REFRESH_TOKEN_2
};
});
it('creates a device and sends an email with credentials uid', async () => {
await oauthUtils.newTokenNotification(db, oauthdb, mailer, devices, request, grant);
assert.equal(oauthdb.checkAccessToken.callCount, 0);
assert.equal(mailer.sendNewDeviceLoginNotification.callCount, 1, 'sent email notification');
assert.equal(devices.upsert.callCount, 1, 'created a device');
const args = devices.upsert.args[0];
assert.equal(args[1].refreshTokenId, request.auth.credentials.refreshTokenId);
});
it('creates a device and sends an email with checkAccessToken uid', async () => {
credentials = {};
request = mocks.mockRequest({credentials});
await oauthUtils.newTokenNotification(db, oauthdb, mailer, devices, request, grant);
assert.equal(oauthdb.checkAccessToken.callCount, 1);
assert.equal(mailer.sendNewDeviceLoginNotification.callCount, 1, 'sent email notification');
assert.equal(devices.upsert.callCount, 1, 'created a device');
});
it('does nothing for non-NOTIFICATION_SCOPES', async () => {
grant.scope = 'profile';
await oauthUtils.newTokenNotification(db, oauthdb, mailer, devices, request, grant);
assert.equal(oauthdb.checkAccessToken.callCount, 0);
assert.equal(mailer.sendNewDeviceLoginNotification.callCount, 0);
assert.equal(devices.upsert.callCount, 0);
});
it('uses refreshTokenId from grant if not provided', async () => {
credentials = {
uid: MOCK_UID,
};
request = mocks.mockRequest({credentials});
await oauthUtils.newTokenNotification(db, oauthdb, mailer, devices, request, grant);
assert.equal(oauthdb.checkAccessToken.callCount, 0);
assert.equal(mailer.sendNewDeviceLoginNotification.callCount, 1);
assert.equal(devices.upsert.callCount, 1);
const args = devices.upsert.args[0];
assert.equal(args[1].refreshTokenId, MOCK_REFRESH_TOKEN_ID_2);
});
});

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

@ -87,6 +87,7 @@ const DB_METHOD_NAMES = [
];
const OAUTHDB_METHOD_NAMES = [
'checkAccessToken',
'checkRefreshToken',
'revokeRefreshTokenById',
'getClientInfo',

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

@ -13,6 +13,7 @@ const testUtils = require('../lib/util');
const oauthServerModule = require('../../fxa-oauth-server/lib/server');
const PUBLIC_CLIENT_ID = '3c49430b43dfba77';
const OAUTH_CLIENT_NAME = 'Android Components Reference Browser';
const MOCK_CODE_VERIFIER = 'abababababababababababababababababababababa';
const MOCK_CODE_CHALLENGE = 'YPhkZqm08uTfwjNSiYcx80-NPT9Zn94kHboQW97KyV0';
@ -86,8 +87,12 @@ describe('/oauth/ routes', function () {
}
});
it('successfully grants tokens from sessionToken', async () => {
it('successfully grants tokens from sessionToken and notifies user', async () => {
const SCOPE = 'https://identity.mozilla.com/apps/oldsync';
let devices = await client.devices();
assert.equal(devices.length, 0, 'no devices yet');
const res = await client.grantOAuthTokensFromSessionToken({
grant_type: 'fxa-credentials',
client_id: PUBLIC_CLIENT_ID,
@ -101,11 +106,25 @@ describe('/oauth/ routes', function () {
assert.ok(res.auth_at);
assert.ok(res.expires_in);
assert.ok(res.token_type);
// got an email notification
const emailData = await server.mailbox.waitForEmail(email);
assert.equal(emailData.headers['x-template-name'], 'newDeviceLoginEmail', 'correct template');
assert.equal(emailData.subject, `New sign-in to ${OAUTH_CLIENT_NAME}`, 'has client name');
assert.equal(emailData.headers['x-service-id'], PUBLIC_CLIENT_ID, 'has client id');
// added a new device
devices = await client.devicesWithRefreshToken(res.refresh_token);
assert.equal(devices.length, 1, 'new device');
assert.equal(devices[0].name, OAUTH_CLIENT_NAME);
});
it('successfully grants tokens via authentication code flow, and refresh token flow', async () => {
const SCOPE = 'https://identity.mozilla.com/apps/oldsync openid';
let devices = await client.devices();
assert.equal(devices.length, 0, 'no devices yet');
let res = await client.createAuthorizationCode({
client_id: PUBLIC_CLIENT_ID,
state: 'abc',
@ -116,6 +135,9 @@ describe('/oauth/ routes', function () {
});
assert.ok(res.code);
devices = await client.devices();
assert.equal(devices.length, 0, 'no devices yet');
res = await client.grantOAuthTokens({
client_id: PUBLIC_CLIENT_ID,
code: res.code,
@ -129,6 +151,9 @@ describe('/oauth/ routes', function () {
assert.ok(res.expires_in);
assert.ok(res.token_type);
devices = await client.devices();
assert.equal(devices.length, 1, 'has a new device after the code grant');
const res2 = await client.grantOAuthTokens({
client_id: PUBLIC_CLIENT_ID,
refresh_token: res.refresh_token,
@ -139,5 +164,8 @@ describe('/oauth/ routes', function () {
assert.ok(res.expires_in);
assert.ok(res.token_type);
assert.notEqual(res.access_token, res2.access_token);
devices = await client.devices();
assert.equal(devices.length, 1, 'still only one device after a refresh_token grant');
});
});