Merge pull request #401 from mozilla/purge-access-tokens r=@rfk

fix(tokens): Purge expired access tokens
This commit is contained in:
Vijay 2016-06-29 09:03:13 -04:00 коммит произвёл GitHub
Родитель 4a30e19779 10bbb2405f
Коммит 625df65f46
10 изменённых файлов: 310 добавлений и 8 удалений

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

@ -4,7 +4,8 @@ node_js:
- '0.10'
- '4'
sudo: false
dist: trusty
sudo: true
addons:
apt:
@ -12,6 +13,9 @@ addons:
- ubuntu-toolchain-r-test
packages:
- g++-4.8
- mysql-server-5.6
- mysql-client-core-5.6
- mysql-client-5.6
notifications:
email:
@ -36,9 +40,9 @@ install:
- if [ $TRAVIS_NODE_VERSION == "4" ]; then CXX=g++-4.8 npm install; else npm install; fi
before_script:
- "mysql -NBe 'select version()'"
- "mysql -e 'DROP DATABASE IF EXISTS fxa_oauth;'"
- "mysql -e 'CREATE DATABASE fxa_oauth;'"
- "mysql -u root -NBe 'select version()'"
- "mysql -u root -e 'DROP DATABASE IF EXISTS fxa_oauth;'"
- "mysql -u root -e 'CREATE DATABASE fxa_oauth CHARACTER SET utf8 COLLATE utf8_unicode_ci;'"
script:
- npm run outdated

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

@ -0,0 +1,76 @@
#!/usr/bin/env node
/* 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/.
*
* This is a command line tool that can be used to purge expired tokens
* from the OAuth database. It requires you specify a pocket client id
* before running. Currently, access tokens created from pocket should
* not be deleted even if expired.
*
* Example usage:
*
* node purge_expired_tokens.js --config dev --pocket-id dcdb5ae7add825d2 --token-count 10000 --delay-seconds 1
*
* */
const program = require('commander');
const package = require('./../package.json');
program
.version(package.version)
.option('-c, --config [config]', 'Configuration to use. Ex. dev')
.option('-p, --pocket-id <pocketId>', 'Pocket Client Id. These tokens will not be purged.')
.option('-t, --token-count <tokenCount>', 'Number of tokens to delete.')
.option('-d, --delay-seconds <delaySeconds>', 'Delay (seconds) between each deletion round. (Default: 1 second)')
.parse(process.argv);
program.parse(process.argv);
if (!program.config) {
program.config = 'dev';
}
process.env.NODE_ENV = program.config;
const db = require('../lib/db');
const logger = require('../lib/logging')('bin.purge_expired_tokens');
if (!program.pocketId) {
logger.debug('Required pocket client id!');
process.exit(1);
}
const numberOfTokens = parseInt(program.tokenCount) || 200;
const delaySeconds = parseInt(program.delaySeconds) || 1; // Default 1 seconds
const ignorePocketClientId = program.pocketId;
db.ping().done(function() {
// Only mysql impl supports token deletion at the moment
if (db.purgeExpiredTokens) {
// To reduce the risk of deleting pocket tokens, ensure that the pocket-id passed in
// belongs to a client.
logger.debug('Deleting token amount %s, delay %s, pocketId %s', numberOfTokens, delaySeconds, ignorePocketClientId);
return db.purgeExpiredTokens(numberOfTokens, delaySeconds, ignorePocketClientId)
.then(function () {
logger.info('Purge completed!');
process.exit(0);
})
.catch(function (err) {
logger.error(err);
process.exit(1);
});
} else {
logger.debug('Unable to purge expired tokens, only avalible when using config with mysql database.');
}
}, function(err) {
logger.critical('db.ping', err);
process.exit(1);
});
process.on('uncaughtException', function () {
process.exit(2);
});

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

@ -10,7 +10,8 @@ module.exports = function (grunt) {
options: {
ui: 'bdd',
reporter: 'spec',
require: 'coverage/blanket'
require: 'coverage/blanket',
timeout: 10000
},
src: [
'test/**/*.js',

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

@ -176,6 +176,7 @@ const QUERY_REFRESH_TOKEN_DELETE_USER =
const QUERY_CODE_DELETE_USER = 'DELETE FROM codes WHERE userId=?';
const QUERY_DEVELOPER = 'SELECT * FROM developers WHERE email=?';
const QUERY_DEVELOPER_DELETE = 'DELETE FROM developers WHERE email=?';
const QUERY_PURGE_EXPIRED_TOKENS = 'DELETE FROM tokens WHERE clientId != UNHEX(?) AND expiresAt < NOW() LIMIT ?;';
function firstRow(rows) {
return rows[0];
@ -377,7 +378,7 @@ MysqlStore.prototype = {
scope: Scope(vals.scope),
token: unique.token(),
type: 'bearer',
expiresAt: new Date(Date.now() + (vals.ttl * 1000 || MAX_TTL))
expiresAt: vals.expiresAt || new Date(Date.now() + (vals.ttl * 1000 || MAX_TTL))
};
return this._write(QUERY_ACCESS_TOKEN_INSERT, [
t.clientId,
@ -460,6 +461,54 @@ MysqlStore.prototype = {
});
},
purgeExpiredTokens: function purgeExpiredTokens(numberOfTokens, delaySeconds, ignoreClientId){
var self = this;
return self.getClientDevelopers(ignoreClientId)
.then(function (ignoreClient) {
// This ensures that purgeExpiredTokens can not be called with an invalid ignoreClientId
})
.catch(function(err){
err = new Error('Invalid ignoreClientId, please ensure client exists.');
logger.error(err);
throw err;
})
.then(function () {
var deleteBatchSize = 200;
if (numberOfTokens <= deleteBatchSize) {
deleteBatchSize = numberOfTokens;
}
var deletedItems = 0;
var promiseWhile = P.method(function () {
if (deletedItems >= numberOfTokens) {
return;
}
return self._write(QUERY_PURGE_EXPIRED_TOKENS, [ignoreClientId, deleteBatchSize])
.then(function (res) {
// Break loop if no items were effected by delete.
// All expired tokens have been deleted.
if (res.affectedRows === 0) {
return;
}
deletedItems = deletedItems + res.affectedRows;
return P.delay(delaySeconds)
.then(function () {
return promiseWhile();
});
});
});
return promiseWhile();
})
.then(function() {
logger.debug('purgeExpiredTokens completed');
});
},
removeUser: function removeUser(userId) {
// TODO this should be a transaction or stored procedure
var id = buf(userId);

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

@ -6,4 +6,4 @@
// Update this if you add a new patch, and don't forget to update
// the documentation for the current schema in ../schema.sql.
module.exports.level = 14;
module.exports.level = 15;

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

@ -0,0 +1,5 @@
-- Add index idx_expiresAt to token table
ALTER TABLE tokens ADD INDEX idx_expiresAt (expiresAt), ALGORITHM = INPLACE, LOCK = NONE;
UPDATE dbMetadata SET value = '15' WHERE name = 'schema-patch-level';

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

@ -0,0 +1,5 @@
-- (commented out to avoid accidentally running this in production...)
-- ALTER TABLE tokens DROP INDEX idx_expiresAt;
-- UPDATE dbMetadata SET value = '14' WHERE name = 'schema-patch-level';

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

@ -46,7 +46,8 @@ CREATE TABLE IF NOT EXISTS tokens (
type VARCHAR(16) NOT NULL,
scope VARCHAR(256) NOT NULL,
createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
expiresAt TIMESTAMP NOT NULL
expiresAt TIMESTAMP NOT NULL,
INDEX idx_expiresAt(expiresAt)
) ENGINE=InnoDB CHARACTER SET utf8 COLLATE utf8_unicode_ci;
CREATE TABLE IF NOT EXISTS developers (

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

@ -25,6 +25,7 @@
"dependencies": {
"bluebird": "^2.9.14",
"buf": "0.1.0",
"commander": "^2.9.0",
"convict": "0.8",
"fxa-jwtool": "^0.7.1",
"fxa-notifier-aws": "1.0.0",

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

@ -10,6 +10,8 @@ const hex = require('buf').to.hex;
const db = require('../../lib/db');
const config = require('../../lib/config');
const auth = require('../../lib/auth');
const Promise = require('bluebird');
/*global describe,it,before*/
@ -94,6 +96,164 @@ describe('db', function() {
});
});
if (config.get('db.driver') === 'mysql') {
describe('purgeExpiredTokens', function () {
var clientIdA;
var clientIdB;
var userId;
var email;
function seedTokens (client, userId, email, count, expiresAt) {
var accessTokens = [];
for (var i = 0; i < count; i++) {
accessTokens.push({
clientId: buf(client),
userId: buf(userId),
email: email,
scope: [auth.SCOPE_CLIENT_MANAGEMENT],
expiresAt: expiresAt
});
}
return Promise.each(accessTokens, function (options) {
return db.generateAccessToken(options);
});
}
// Inserts 2000 access tokens with the following breakdown
// ClientIdA - 500 expired, 500 valid
// ClientIdB - 500 expired, 500 valid
before('setup clients', function(){
email = 'asdf@asdf.com';
clientIdA = randomString(8);
clientIdB = randomString(8);
userId = buf(randomString(16));
return db.registerClient({
id: clientIdA,
name: 'ClientA',
hashedSecret: randomString(32),
imageUri: 'https://example.domain/logo',
redirectUri: 'https://example.domain/return?foo=bar',
trusted: true
})
.then( function () {
return db.registerClient({
id: clientIdB,
name: 'ClientB',
hashedSecret: randomString(32),
imageUri: 'https://example.domain/logo',
redirectUri: 'https://example.domain/return?foo=bar',
trusted: true
});
});
});
beforeEach('seed with tokens', function () {
return db._write('DELETE FROM tokens;')
.then( function () {
return seedTokens(clientIdA, userId, email, 500);
})
.then( function () {
return seedTokens(clientIdB, userId, email, 500);
})
.then( function () {
return seedTokens(clientIdA, userId, email, 500, new Date(Date.now() - (1000 * 600)));
})
.then( function () {
return seedTokens(clientIdB, userId, email, 500, new Date(Date.now() - (1000 * 600)));
});
});
it('should fail purgeExpiredTokens without ignoreClientId', function() {
return db.purgeExpiredTokens(1000, 5)
.then( function () {
assert.fail('Purge token should fail with no ignoreClientId');
})
.catch( function (error) {
assert.equal(error.message, 'Invalid ignoreClientId, please ensure client exists.');
});
});
it('should call purgeExpiredTokens and ignore client', function() {
return db.purgeExpiredTokens(1000, 0, clientIdA)
.then( function () {
// Check clientA tokens not deleted
return db._read('SELECT COUNT(*) AS count FROM fxa_oauth.tokens WHERE clientId=UNHEX(?);', [
clientIdA
]);
})
.then( function (result) {
assert.equal(result[0].count, 1000);
})
.then( function () {
// Check clientB expired tokens are deleted
return db._read('SELECT COUNT(*) AS count FROM fxa_oauth.tokens WHERE clientId=UNHEX(?) AND expiresAt < NOW();', [
clientIdB
]);
})
.then( function (result) {
assert.equal(result[0].count, 0);
})
.then( function () {
// Check clientB unexpired tokens are not deleted
return db._read('SELECT COUNT(*) AS count FROM fxa_oauth.tokens WHERE clientId=UNHEX(?) AND expiresAt > NOW();', [
clientIdB
]);
})
.then( function (result) {
assert.equal(result[0].count, 500);
})
.then( function () {
// Check the total tokens
return db._read('SELECT COUNT(*) AS count FROM fxa_oauth.tokens;');
})
.then( function (result) {
assert.equal(result[0].count, 1500);
});
});
it('should call purgeExpiredTokens and only purge 100 items', function() {
return db.purgeExpiredTokens(100, 0, clientIdA)
.then( function () {
// Check clientA tokens not deleted
return db._read('SELECT COUNT(*) AS count FROM fxa_oauth.tokens WHERE clientId=UNHEX(?);', [
clientIdA
]);
})
.then( function (result) {
assert.equal(result[0].count, 1000);
})
.then( function () {
// Check clientB only 100 expired tokens are deleted
return db._read('SELECT COUNT(*) AS count FROM fxa_oauth.tokens WHERE clientId=UNHEX(?) AND expiresAt < NOW();', [
clientIdB
]);
})
.then( function (result) {
assert.equal(result[0].count, 400);
})
.then( function () {
// Check clientB unexpired tokens are not deleted
return db._read('SELECT COUNT(*) AS count FROM fxa_oauth.tokens WHERE clientId=UNHEX(?) AND expiresAt > NOW();', [
clientIdB
]);
})
.then( function (result) {
assert.equal(result[0].count, 500);
})
.then( function () {
// Check the total tokens
return db._read('SELECT COUNT(*) AS count FROM fxa_oauth.tokens;');
})
.then( function (result) {
assert.equal(result[0].count, 1900);
});
});
});
}
describe('removeUser', function () {
var clientId = buf(randomString(8));
var userId = buf(randomString(16));