Throttling
After each failed attempt, the user has to wait longer before their next attempt.
Memory storage
timeoutSeconds
holds the number of seconds to lock out the user for.
export class Throttler<_Key> {
public timeoutSeconds: number[];
private storage = new Map<_Key, ThrottlingCounter>();
constructor(timeoutSeconds: number[]) {
this.timeoutSeconds = timeoutSeconds;
}
public consume(key: _Key): boolean {
let counter = this.storage.get(key) ?? null;
const now = Date.now();
if (counter === null) {
counter = {
index: 0,
updatedAt: now
};
this.storage.set(key, counter);
return true;
}
const allowed = now - counter.updatedAt >= this.timeoutSeconds[counter.index] * 1000;
if (!allowed) {
return false;
}
counter.updatedAt = now;
counter.index = Math.min(counter.index + 1, this.timeoutSeconds.length - 1);
this.storage.set(key, counter);
return true;
}
public reset(key: _Key): void {
this.storage.delete(key);
}
}
interface ThrottlingCounter {
index: number;
updatedAt: number;
}
Here, on each failed sign in attempt, the lockout time gets extended with a max of 5 minutes.
const throttler = new Throttler<number>([1, 2, 4, 8, 16, 30, 60, 180, 300]);
if (!throttler.consume(userId)) {
throw new Error("Too many requests");
}
const validPassword = verifyPassword(password);
if (!validPassword) {
throw new Error("Invalid password");
}
throttler.reset(user.id);
Redis
We'll use Lua scripts to ensure queries are atomic. timeoutSeconds
holds the number of seconds to lock out the user for.
-- Returns 1 if allowed, 0 if not
local key = KEYS[1]
local now = tonumber(ARGV[1])
local timeoutSeconds = {1, 2, 4, 8, 16, 30, 60, 180, 300}
local fields = redis.call("HGETALL", key)
if #fields == 0 then
redis.call("HSET", key, "index", 1, "updated_at", now)
return {1}
end
local index = 0
local updatedAt = 0
for i = 1, #fields, 2 do
if fields[i] == "index" then
index = tonumber(fields[i+1])
elseif fields[i] == "updated_at" then
updatedAt = tonumber(fields[i+1])
end
end
local allowed = now - updatedAt >= timeoutSeconds[index]
if not allowed then
return {0}
end
index = math.min(index + 1, #timeoutSeconds)
redis.call("HSET", key, "index", index, "updated_at", now)
return {1}
Load the script and retrieve the script hash.
const SCRIPT_SHA = await client.scriptLoad(script);
Reference the script with the hash.
export class Throttler {
private storageKey: string;
constructor(storageKey: string) {
this.storageKey = storageKey;
}
public async consume(key: string): Promise<boolean> {
const result = await client.EVALSHA(SCRIPT_SHA, {
keys: [`${this.storageKey}:${key}`],
arguments: [Math.floor(Date.now() / 1000).toString()]
});
return Boolean(result[0]);
}
public async reset(key: string): Promise<void> {
await client.DEL(key);
}
}
Here, on each failed sign in attempt, the lockout time gets extended.
const throttler = new Throttler<number>("login_throttler");
if (!throttler.consume(userId)) {
throw new Error("Too many requests");
}
const validPassword = verifyPassword(password);
if (!validPassword) {
throw new Error("Invalid password");
}
throttler.reset(user.id);