Merge pull request #401 from mozilla/purge-access-tokens r=@rfk
fix(tokens): Purge expired access tokens
This commit is contained in:
Коммит
625df65f46
12
.travis.yml
12
.travis.yml
|
@ -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",
|
||||
|
|
160
test/db/index.js
160
test/db/index.js
|
@ -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));
|
||||
|
|
Загрузка…
Ссылка в новой задаче