Skip to content
This repository was archived by the owner on Apr 3, 2019. It is now read-only.

Commit 5c6622c

Browse files
authored
fix(pruning): Avoid accidental full-table scans when pruning session tokens. (#295); r=philbooth
1 parent 0790b89 commit 5c6622c

File tree

5 files changed

+114
-3
lines changed

5 files changed

+114
-3
lines changed

lib/db/mysql.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1110,7 +1110,7 @@ module.exports = function (log, error) {
11101110
// exposed for testing only
11111111
MySql.prototype.retryable_ = retryable
11121112

1113-
const PRUNE = 'CALL prune_6(?)'
1113+
const PRUNE = 'CALL prune_7(?)'
11141114
MySql.prototype.pruneTokens = function () {
11151115
log.info('MySql.pruneTokens')
11161116

lib/db/patch.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@
44

55
// The expected patch level of the database. Update if you add a new
66
// patch in the ./schema/ directory.
7-
module.exports.level = 68
7+
module.exports.level = 69

lib/db/schema/patch-068-069.sql

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
SET NAMES utf8mb4 COLLATE utf8mb4_bin;
2+
3+
-- We might have gotten ourselves into a state where 'sessionTokensPrunedUntil'
4+
-- was set to the empty string. Start it over from zero.
5+
UPDATE dbMetadata SET value = '0' WHERE name = 'sessionTokensPrunedUntil' AND value = '';
6+
7+
-- Update prune to limit total number of sessionTokens examined,
8+
-- and avoid producing the above empty-string bug.
9+
CREATE PROCEDURE `prune_7` (IN `olderThan` BIGINT UNSIGNED)
10+
BEGIN
11+
DECLARE EXIT HANDLER FOR SQLEXCEPTION
12+
BEGIN
13+
ROLLBACK;
14+
RESIGNAL;
15+
END;
16+
17+
SELECT @lockAcquired := GET_LOCK('fxa-auth-server.prune-lock', 3);
18+
19+
IF @lockAcquired THEN
20+
DELETE FROM accountResetTokens WHERE createdAt < olderThan ORDER BY createdAt LIMIT 10000;
21+
DELETE FROM passwordForgotTokens WHERE createdAt < olderThan ORDER BY createdAt LIMIT 10000;
22+
DELETE FROM passwordChangeTokens WHERE createdAt < olderThan ORDER BY createdAt LIMIT 10000;
23+
DELETE FROM unblockCodes WHERE createdAt < olderThan ORDER BY createdAt LIMIT 10000;
24+
DELETE FROM signinCodes WHERE createdAt < olderThan ORDER BY createdAt LIMIT 10000;
25+
26+
-- Pruning session tokens is complicated because:
27+
-- * we can't prune them if there is an associated device record, and
28+
-- * we have to delete from both sessionTokens and unverifiedTokens tables, and
29+
-- * MySQL won't allow `LIMIT` to be used in a multi-table delete.
30+
-- To achieve all this in an efficient manner, we prune tokens within a specific
31+
-- time window rather than using a `LIMIT` clause. At the end of each run we
32+
-- record the new lower-bound on creation time for tokens that might have expired.
33+
START TRANSACTION;
34+
35+
-- Step 1: Find out how far we got on previous iterations.
36+
SELECT @pruneFrom := value FROM dbMetadata WHERE name = 'sessionTokensPrunedUntil';
37+
38+
-- Step 2: Calculate what timestamp we will reach on this iteration
39+
-- if we purge a sensibly-sized batch of tokens.
40+
-- N.B. We deliberately do not filter on whether the token has
41+
-- a device here. We want to limit the number of tokens that we
42+
-- *examine*, regardless of whether it actually delete them.
43+
SELECT @pruneUntil := MAX(createdAt) FROM (
44+
SELECT createdAt FROM sessionTokens
45+
WHERE createdAt >= @pruneFrom AND createdAt < olderThan
46+
ORDER BY createdAt
47+
LIMIT 10000
48+
) AS candidatesForPruning;
49+
50+
-- This will be NULL if there are no expired tokens,
51+
-- in which case we have nothing to do.
52+
IF @pruneUntil IS NOT NULL THEN
53+
54+
-- Step 3: Prune sessionTokens and unverifiedTokens tables.
55+
-- Here we *do* filter on whether a device record exists.
56+
-- We might not actually delete any tokens, but we will definitely
57+
-- be able to increase 'sessionTokensPrunedUntil' for the next run.
58+
DELETE st, ut
59+
FROM sessionTokens AS st
60+
LEFT JOIN unverifiedTokens AS ut
61+
ON st.tokenId = ut.tokenId
62+
WHERE st.createdAt > @pruneFrom
63+
AND st.createdAt <= @pruneUntil
64+
AND NOT EXISTS (
65+
SELECT sessionTokenId FROM devices
66+
WHERE uid = st.uid AND sessionTokenId = st.tokenId
67+
);
68+
69+
-- Step 4: Tell following iterations how far we got.
70+
UPDATE dbMetadata
71+
SET value = @pruneUntil
72+
WHERE name = 'sessionTokensPrunedUntil';
73+
74+
END IF;
75+
76+
COMMIT;
77+
78+
SELECT RELEASE_LOCK('fxa-auth-server.prune-lock');
79+
END IF;
80+
END;
81+
82+
UPDATE dbMetadata SET value = '69' WHERE name = 'schema-patch-level';
83+

lib/db/schema/patch-069-068.sql

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
-- SET NAMES utf8mb4 COLLATE utf8mb4_bin;
2+
3+
-- DROP PROCEDURE `prune_7`;
4+
5+
-- UPDATE dbMetadata SET value = '68' WHERE name = 'schema-patch-level';
6+

test/local/prune_tokens.js

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ describe('prune tokens', () => {
3939
const unprunableSessionTokenId = crypto.randomBytes(16).toString('hex')
4040
const tokenVerificationId = crypto.randomBytes(8).toString('hex')
4141
const unverifiedKeyFetchToken = Object.assign({}, user.keyFetchToken, { tokenVerificationId })
42+
const initialPrunedUntilValue = Date.now() - TOKEN_PRUNE_AGE * 2 + 1
4243
return db.createAccount(user.accountId, user.account)
4344
.then(function() {
4445
return db.createPasswordForgotToken(user.passwordForgotTokenId, user.passwordForgotToken)
@@ -55,7 +56,7 @@ describe('prune tokens', () => {
5556
db.createSessionToken(unprunableSessionTokenId, user.sessionToken),
5657
db.createSigninCode(signinCode, user.accountId, Date.now() - TOKEN_PRUNE_AGE),
5758
db.write('UPDATE dbMetadata SET value = ? WHERE name = \'sessionTokensPrunedUntil\'', [
58-
Date.now() - TOKEN_PRUNE_AGE * 2 + 1
59+
initialPrunedUntilValue
5960
])
6061
])
6162
})
@@ -160,6 +161,27 @@ describe('prune tokens', () => {
160161
// unverifiedTokens must not be pruned if they belong to keyFetchTokens
161162
assert.equal(keyFetchToken.tokenVerificationId, tokenVerificationId)
162163
})
164+
.then(() => {
165+
// 'sessionTokensPrunedUntil' should have increased after pruning
166+
const sql = 'SELECT value FROM dbMetadata WHERE name = \'sessionTokensPrunedUntil\''
167+
return db.read(sql)
168+
})
169+
.then((res) => {
170+
assert.equal(res.length, 1, 'sessionTokensPrunedUntil still exists')
171+
assert.ok(res[0].value, 'sessionTokensPrunedUntil is not falsy')
172+
const updatedPrunedUntilValue = parseInt(res[0].value, 10)
173+
assert.ok(updatedPrunedUntilValue > initialPrunedUntilValue, 'sessionTokensPrunedUntil increased')
174+
// Prune again, so we can check that it gracefully handles
175+
// the case when there's nothing to prune.
176+
return db.pruneTokens().then(() => {
177+
const sql = 'SELECT value FROM dbMetadata WHERE name = \'sessionTokensPrunedUntil\''
178+
return db.read(sql)
179+
}).then((res) => {
180+
assert.equal(res.length, 1, 'sessionTokensPrunedUntil still exists')
181+
assert.ok(res[0].value, 'sessionTokensPrunedUntil is not falsy')
182+
assert.equal(parseInt(res[0].value, 10), updatedPrunedUntilValue, 'sessionTokensPrunedUntil did not change')
183+
})
184+
})
163185
}
164186
)
165187

0 commit comments

Comments
 (0)