feat(auth): Story 2.1 — In-Memory Rate-Limiter (pro Key)

This commit is contained in:
Kenearos 2026-07-07 22:36:10 +02:00
parent 07f1bccff0
commit 08c32bcf0f
2 changed files with 41 additions and 0 deletions

23
server/ratelimit.js Normal file
View file

@ -0,0 +1,23 @@
// In-Memory Rate-Limiter pro Key (Sliding Fixed-Window).
// ponytail: per-Prozess, resettet bei Container-Restart — bei Magic-Link (kein
// Passwort-Brute-Force) akzeptierte Ceiling; Persistenz = YAGNI.
const buckets = new Map();
/**
* Zählt einen Treffer für `key`. Gibt true zurück, solange das Limit im Fenster
* nicht überschritten ist, sonst false ( Aufrufer antwortet 429).
*/
function hit(key, limit, windowMin) {
const now = Date.now();
let b = buckets.get(key);
if (!b || now >= b.resetAt) {
b = { count: 0, resetAt: now + windowMin * 60 * 1000 };
buckets.set(key, b);
}
b.count += 1;
return b.count <= limit;
}
function _reset() { buckets.clear(); } // nur für Tests
module.exports = { hit, _reset };

18
server/ratelimit.test.js Normal file
View file

@ -0,0 +1,18 @@
const { test } = require('node:test');
const assert = require('node:assert');
const { hit, _reset } = require('./ratelimit');
test('erlaubt bis zum Limit, blockt danach', () => {
_reset();
assert.strictEqual(hit('a@x.de', 3, 15), true);
assert.strictEqual(hit('a@x.de', 3, 15), true);
assert.strictEqual(hit('a@x.de', 3, 15), true);
assert.strictEqual(hit('a@x.de', 3, 15), false, '4. Anfrage geblockt');
});
test('verschiedene Keys sind unabhängig (E-Mail vs IP)', () => {
_reset();
assert.strictEqual(hit('email:a@x.de', 1, 15), true);
assert.strictEqual(hit('email:a@x.de', 1, 15), false);
assert.strictEqual(hit('ip:1.2.3.4', 100, 15), true, 'anderer Key unberührt');
});