blacklist with automatic garbage collector
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
Timo Hocker 2022-09-09 15:49:53 +02:00
parent 31f739d4b8
commit 64d4f00629
No known key found for this signature in database
GPG Key ID: 3B86485AC71C835C
9 changed files with 153 additions and 51 deletions

View File

@ -6,13 +6,13 @@
*/ */
import { import {
create_salt,
sign_object, sign_object,
verify_signature_get_info verify_signature_get_info
} from '@sapphirecode/crypto-helper'; } from '@sapphirecode/crypto-helper';
import keystore from './KeyStore'; import keystore from './KeyStore';
import blacklist from './Blacklist'; import blacklist from './Blacklist';
import { debug } from './debug'; import { debug } from './debug';
import { generate_token_id } from './token_id';
const logger = debug ('authority'); const logger = debug ('authority');
@ -102,13 +102,15 @@ class Authority {
const log = logger.extend ('sign'); const log = logger.extend ('sign');
log ('signing new %s', type); log ('signing new %s', type);
const time = Date.now (); const time = Date.now ();
const valid_until = time + (valid_for * 1e3);
const key = await keystore.get_sign_key (time / 1000, valid_for); const key = await keystore.get_sign_key (time / 1000, valid_for);
const attributes = { const attributes = {
id: create_salt (), id: generate_token_id (new Date (valid_until)),
iat: time, iat: time,
iss: keystore.instance_id, iss: keystore.instance_id,
type, type,
valid_for, valid_for,
valid_until,
next_module: options?.next_module next_module: options?.next_module
}; };
const signature = sign_object (options?.data, key, attributes); const signature = sign_object (options?.data, key, attributes);

View File

@ -7,19 +7,31 @@
import { debug } from './debug'; import { debug } from './debug';
import { redis_blacklist_store } from './RedisData/RedisBlacklistStore'; import { redis_blacklist_store } from './RedisData/RedisBlacklistStore';
import { parse_token_id } from './token_id';
const logger = debug ('blacklist'); const logger = debug ('blacklist');
interface Signature { interface Signature {
hash: string; token_id: string;
iat: number;
valid_until: Date;
}
interface ExportedSignature {
token_id: string;
iat: number; iat: number;
} }
class Blacklist { class Blacklist {
private _signatures: Signature[]; private _signatures: Signature[];
private _interval: NodeJS.Timeout;
public constructor () { public constructor () {
this._signatures = []; this._signatures = [];
this._interval = setInterval (
this.garbage_collect.bind (this),
3600000
);
} }
public async clear ( public async clear (
@ -34,10 +46,15 @@ class Blacklist {
} }
} }
public async add_signature (hash: string): Promise<void> { public async add_signature (token_id: string): Promise<void> {
logger.extend ('add_signature') ('blacklisting signature %s', hash); logger.extend ('add_signature') ('blacklisting signature %s', token_id);
this._signatures.push ({ iat: Date.now (), hash }); const parsed = parse_token_id (token_id);
await redis_blacklist_store.add (hash); this._signatures.push ({
iat: Date.now (),
token_id,
valid_until: parsed.valid_until
});
await redis_blacklist_store.add (token_id, parsed.valid_until);
} }
public async remove_signature (signature: number | string): Promise<void> { public async remove_signature (signature: number | string): Promise<void> {
@ -48,13 +65,20 @@ class Blacklist {
log ('received string, searching through signatures'); log ('received string, searching through signatures');
key = signature; key = signature;
for (let i = this._signatures.length - 1; i >= 0; i--) { for (let i = this._signatures.length - 1; i >= 0; i--) {
if (this._signatures[i].hash === signature) if (this._signatures[i].token_id === signature) {
log ('removing sigature %s at %d', signature, i);
this._signatures.splice (i, 1); this._signatures.splice (i, 1);
}
} }
} }
else { else {
log ('received index, removing at index'); log (
key = this._signatures[signature].hash; 'received index, removing signature %s at index %s',
this._signatures[signature].token_id,
signature
);
key = this._signatures[signature].token_id;
this._signatures.splice (signature, 1); this._signatures.splice (signature, 1);
} }
await redis_blacklist_store.remove (key); await redis_blacklist_store.remove (key);
@ -64,7 +88,7 @@ class Blacklist {
const log = logger.extend ('is_valid'); const log = logger.extend ('is_valid');
log ('checking signature for blacklist entry %s', hash); log ('checking signature for blacklist entry %s', hash);
for (const sig of this._signatures) { for (const sig of this._signatures) {
if (sig.hash === hash) { if (sig.token_id === hash) {
log ('found matching blacklist entry'); log ('found matching blacklist entry');
return false; return false;
} }
@ -80,22 +104,44 @@ class Blacklist {
return true; return true;
} }
public export_blacklist (): Signature[] { public export_blacklist (): ExportedSignature[] {
logger.extend ('export_blacklist') ('exporting blacklist'); logger.extend ('export_blacklist') ('exporting blacklist');
return this._signatures; return this._signatures.map ((v) => ({
iat: v.iat,
token_id: v.token_id
}));
} }
public import_blacklist (data: Signature[]): void { public import_blacklist (data: ExportedSignature[]): void {
logger.extend ('import_blacklist') ( logger.extend ('import_blacklist') (
'importing %d blacklist entries', 'importing %d blacklist entries',
data.length data.length
); );
this._signatures.push (...data); for (const token of data) {
const parsed = parse_token_id (token.token_id);
this._signatures.push ({
token_id: token.token_id,
iat: token.iat,
valid_until: parsed.valid_until
});
}
} }
public sync_redis (url: string): void { public sync_redis (url: string): void {
redis_blacklist_store.connect (url); redis_blacklist_store.connect (url);
} }
private async garbage_collect (): Promise<void> {
const log = logger.extend ('garbage_collect');
const time = new Date;
log ('removing signatures expired before', time);
for (let i = this._signatures.length - 1; i >= 0; i--) {
if (this._signatures[i].valid_until < time) {
log ('signature %s expired', this._signatures[i].token_id);
await this.remove_signature (i);
}
}
}
} }
const bl = (new Blacklist); const bl = (new Blacklist);

View File

@ -27,7 +27,7 @@ class KeyStore {
public constructor () { public constructor () {
this._interval = setInterval (() => { this._interval = setInterval (() => {
this.garbage_collect (); this.garbage_collect ();
}, renew_interval); }, renew_interval * 1000);
this._instance = to_b58 (random_hex (16), 'hex'); this._instance = to_b58 (random_hex (16), 'hex');
logger.extend ('constructor') ( logger.extend ('constructor') (
'created keystore instance %s', 'created keystore instance %s',

View File

@ -11,14 +11,18 @@ import { Redis } from '../Redis';
const logger = debug ('RedisBlacklistStore'); const logger = debug ('RedisBlacklistStore');
export class RedisBlacklistStore extends Redis { export class RedisBlacklistStore extends Redis {
public async add (key: string): Promise<void> { public async add (key: string, valid_until: Date): Promise<void> {
const log = logger.extend ('set'); const log = logger.extend ('set');
log ('trying to add key %s to redis blacklist', key); log ('trying to add key %s to redis blacklist', key);
if (!this.is_active) { if (!this.is_active) {
log ('redis is inactive, skipping'); log ('redis is inactive, skipping');
return; return;
} }
await this.redis.sadd ('blacklist', key); await this.redis.setex (
`blacklist_${key}`,
(valid_until.getTime () - Date.now ()) / 1000,
1
);
log ('saved key'); log ('saved key');
} }
@ -29,7 +33,7 @@ export class RedisBlacklistStore extends Redis {
log ('redis is inactive, skipping'); log ('redis is inactive, skipping');
return; return;
} }
await this.redis.srem ('blacklist', key); await this.redis.del (`blacklist_${key}`);
log ('removed key'); log ('removed key');
} }
@ -40,7 +44,7 @@ export class RedisBlacklistStore extends Redis {
log ('redis is inactive, skipping'); log ('redis is inactive, skipping');
return false; return false;
} }
const res = await this.redis.sismember ('blacklist', key) === 1; const res = await this.redis.exists (`blacklist_${key}`) === 1;
log ('found key %s', res); log ('found key %s', res);
return res; return res;
} }

21
lib/token_id.ts Normal file
View File

@ -0,0 +1,21 @@
import { create_salt } from '@sapphirecode/crypto-helper';
import { to_b58 } from '@sapphirecode/encoding-helper';
export function generate_token_id (valid_until: Date) {
const salt = create_salt ();
return `${to_b58 (salt, 'hex')};${valid_until.toISOString ()}`;
}
export function parse_token_id (id: string) {
// eslint-disable-next-line max-len
const regex = /^(?<hash>[A-HJ-NP-Za-km-z1-9]+);(?<date>\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d{3}Z)$/u;
const result = regex.exec (id);
if (result === null)
throw new Error (`invalid token id ${id}`);
if (typeof result.groups === 'undefined')
throw new Error ('invalid state');
return {
hash: result.groups.hash as string,
valid_until: new Date (result.groups.date as string)
};
}

View File

@ -1,6 +1,6 @@
{ {
"name": "@sapphirecode/auth-server-helper", "name": "@sapphirecode/auth-server-helper",
"version": "4.0.0", "version": "4.0.1",
"main": "dist/lib/index.js", "main": "dist/lib/index.js",
"author": { "author": {
"name": "Timo Hocker", "name": "Timo Hocker",

View File

@ -6,10 +6,15 @@
*/ */
import blacklist, { Blacklist } from '../../lib/Blacklist'; import blacklist, { Blacklist } from '../../lib/Blacklist';
import { generate_token_id, parse_token_id } from '../../lib/token_id';
import { clock_finalize, clock_setup } from '../Helper'; import { clock_finalize, clock_setup } from '../Helper';
// eslint-disable-next-line max-lines-per-function // eslint-disable-next-line max-lines-per-function
describe ('blacklist', () => { describe ('blacklist', () => {
const token1 = generate_token_id (new Date (Date.now () + 3600000));
const token2 = generate_token_id (new Date (Date.now () + 3600000));
const token3 = generate_token_id (new Date (Date.now () + 3600000));
beforeAll (() => { beforeAll (() => {
clock_setup (); clock_setup ();
}); });
@ -19,77 +24,88 @@ describe ('blacklist', () => {
}); });
it ('should validate any string', async () => { it ('should validate any string', async () => {
expect (await blacklist.is_valid ('foo')) expect (await blacklist.is_valid (token1))
.toBeTrue (); .toBeTrue ();
expect (await blacklist.is_valid ('bar')) expect (await blacklist.is_valid (token2))
.toBeTrue (); .toBeTrue ();
expect (await blacklist.is_valid ('baz')) expect (await blacklist.is_valid (token3))
.toBeTrue (); .toBeTrue ();
}); });
it ('should blacklist strings', async () => { it ('should blacklist strings', async () => {
await blacklist.add_signature ('foo'); await blacklist.add_signature (token1);
await blacklist.add_signature ('bar'); await blacklist.add_signature (token2);
expect (await blacklist.is_valid ('foo')) expect (await blacklist.is_valid (token1))
.toBeFalse (); .toBeFalse ();
expect (await blacklist.is_valid ('bar')) expect (await blacklist.is_valid (token2))
.toBeFalse (); .toBeFalse ();
expect (await blacklist.is_valid ('baz')) expect (await blacklist.is_valid (token3))
.toBeTrue (); .toBeTrue ();
}); });
it ('should remove one string', async () => { it ('should remove one string', async () => {
await blacklist.remove_signature ('foo'); await blacklist.remove_signature (token1);
expect (await blacklist.is_valid ('foo')) expect (await blacklist.is_valid (token1))
.toBeTrue (); .toBeTrue ();
expect (await blacklist.is_valid ('bar')) expect (await blacklist.is_valid (token2))
.toBeFalse (); .toBeFalse ();
expect (await blacklist.is_valid ('baz')) expect (await blacklist.is_valid (token3))
.toBeTrue (); .toBeTrue ();
}); });
it ('should clear after time', async () => { it ('should clear after time', async () => {
jasmine.clock () jasmine.clock ()
.tick (5000); .tick (5000);
await blacklist.add_signature ('baz'); await blacklist.add_signature (token3);
await blacklist.clear (Date.now () - 100); await blacklist.clear (Date.now () - 100);
expect (await blacklist.is_valid ('foo')) expect (await blacklist.is_valid (token1))
.toBeTrue (); .toBeTrue ();
expect (await blacklist.is_valid ('bar')) expect (await blacklist.is_valid (token2))
.toBeTrue (); .toBeTrue ();
expect (await blacklist.is_valid ('baz')) expect (await blacklist.is_valid (token3))
.toBeFalse (); .toBeFalse ();
}); });
it ('should clear all', async () => { it ('should clear all', async () => {
await blacklist.add_signature ('foo'); await blacklist.add_signature (token1);
await blacklist.add_signature ('bar'); await blacklist.add_signature (token2);
await blacklist.add_signature ('baz'); await blacklist.add_signature (token3);
expect (await blacklist.is_valid ('foo')) expect (await blacklist.is_valid (token1))
.toBeFalse (); .toBeFalse ();
expect (await blacklist.is_valid ('bar')) expect (await blacklist.is_valid (token2))
.toBeFalse (); .toBeFalse ();
expect (await blacklist.is_valid ('baz')) expect (await blacklist.is_valid (token3))
.toBeFalse (); .toBeFalse ();
await blacklist.clear (); await blacklist.clear ();
expect (await blacklist.is_valid ('foo')) expect (await blacklist.is_valid (token1))
.toBeTrue (); .toBeTrue ();
expect (await blacklist.is_valid ('bar')) expect (await blacklist.is_valid (token2))
.toBeTrue (); .toBeTrue ();
expect (await blacklist.is_valid ('baz')) expect (await blacklist.is_valid (token3))
.toBeTrue (); .toBeTrue ();
}); });
it ('should export and import data', async () => { it ('should export and import data', async () => {
await blacklist.add_signature ('baz'); const time = new Date;
const exp = blacklist.export_blacklist (); const token = generate_token_id (time);
await blacklist.add_signature (token);
// eslint-disable-next-line dot-notation // eslint-disable-next-line dot-notation
expect (blacklist['_signatures']) expect (blacklist['_signatures'])
.toEqual (exp); .toEqual ([
{
token_id: token,
iat: time.getTime (),
valid_until: time
}
]);
const exp = blacklist.export_blacklist ();
expect (exp)
.toEqual ([ { token_id: token, iat: time.getTime () } ]);
const bl2 = (new Blacklist); const bl2 = (new Blacklist);
bl2.import_blacklist (exp); bl2.import_blacklist (exp);
// eslint-disable-next-line dot-notation // eslint-disable-next-line dot-notation
expect (bl2['_signatures']) expect (bl2['_signatures'])
.toEqual (exp); // eslint-disable-next-line dot-notation
.toEqual (blacklist['_signatures']);
}); });
}); });

View File

@ -192,7 +192,7 @@ describe ('gateway', () => {
expect (resp.statusCode) expect (resp.statusCode)
.toEqual (200); .toEqual (200);
const blacklisted = blacklist.export_blacklist () const blacklisted = blacklist.export_blacklist ()
.map ((v) => v.hash); .map ((v) => v.token_id);
expect (blacklisted) expect (blacklisted)
.toContain (token.id); .toContain (token.id);
expect (blacklisted) expect (blacklisted)

13
test/spec/token_id.ts Normal file
View File

@ -0,0 +1,13 @@
import { generate_token_id, parse_token_id } from '../../lib/token_id';
describe ('token_id', () => {
it ('should always generate valid tokens', () => {
for (let i = 0; i < 1000; i++) {
const date = new Date;
const token_id = generate_token_id (new Date);
const parsed = parse_token_id (token_id);
expect (parsed.valid_until)
.toEqual (date);
}
});
});