Login-Rate-Limit: 5 Fehlversuche pro IP → 5 Min Sperre (konfigurierbar)

This commit is contained in:
nico committed 2026-08-26 17:26:41 +02:00
1 parent fe923e650e
commit 5d77fa6f26
5 files changed
+1082 -6

No files matched your search

+3
View File
@@ -17,3 +17,6 @@ THINKING=enabled
# VAPID-Schlüsselpaar für Web Push (wird automatisch generiert) # VAPID-Schlüsselpaar für Web Push (wird automatisch generiert)
VAPID_PUBLIC_KEY= VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY= VAPID_PRIVATE_KEY=
# Login-Schutz: Fehlversuche pro IP bevor gesperrt wird, und Sperrdauer
LOGIN_MAX_ATTEMPTS=5
LOGIN_LOCK_MINUTES=5
+2
View File
@@ -37,6 +37,8 @@ erzeugt und in die `.env` geschrieben.
| `MODEL` | nein | Standard `deepseek-v4-flash` | | `MODEL` | nein | Standard `deepseek-v4-flash` |
| `THINKING` | nein | `enabled` (Standard) oder `disabled` | | `THINKING` | nein | `enabled` (Standard) oder `disabled` |
| `DEEPSEEK_BASE_URL` | nein | Für Tests/Proxies überschreibbar | | `DEEPSEEK_BASE_URL` | nein | Für Tests/Proxies überschreibbar |
| `LOGIN_MAX_ATTEMPTS` | nein | Fehlversuche pro IP vor Sperre (Standard 5) |
| `LOGIN_LOCK_MINUTES` | nein | Sperrdauer nach Fehlversuchen (Standard 5) |
## Deployment mit Docker/Podman (empfohlen) ## Deployment mit Docker/Podman (empfohlen)
+2
View File
@@ -55,6 +55,8 @@ const config = {
model: process.env.MODEL || env.MODEL || 'deepseek-v4-flash', model: process.env.MODEL || env.MODEL || 'deepseek-v4-flash',
thinking: process.env.THINKING || env.THINKING || 'enabled', thinking: process.env.THINKING || env.THINKING || 'enabled',
baseUrl: process.env.DEEPSEEK_BASE_URL || env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com', baseUrl: process.env.DEEPSEEK_BASE_URL || env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com',
loginMaxAttempts: Number(process.env.LOGIN_MAX_ATTEMPTS || env.LOGIN_MAX_ATTEMPTS || 5),
loginLockMinutes: Number(process.env.LOGIN_LOCK_MINUTES || env.LOGIN_LOCK_MINUTES || 5),
sessionSecret: ensure('SESSION_SECRET', () => crypto.randomBytes(32).toString('hex')), sessionSecret: ensure('SESSION_SECRET', () => crypto.randomBytes(32).toString('hex')),
vapidPublicKey: vapid.publicKey, vapidPublicKey: vapid.publicKey,
vapidPrivateKey: vapid.privateKey, vapidPrivateKey: vapid.privateKey,
+1017
View File
File diff suppressed because it is too large. Load diff
+58 -6
View File
@@ -68,17 +68,69 @@ app.use((req, res, next) => {
next(); next();
}); });
// ---------------------------------------------------------------------------
// Login-Rate-Limit: X Fehlversuche pro IP → Y Minuten Sperre
// ---------------------------------------------------------------------------
const loginAttempts = new Map(); // ip -> { count, lockedUntil }
function checkLoginLock(ip) {
const entry = loginAttempts.get(ip);
if (entry && entry.lockedUntil > Date.now()) {
return Math.ceil((entry.lockedUntil - Date.now()) / 1000);
}
return 0;
}
function registerLoginFail(ip) {
const entry = loginAttempts.get(ip) || { count: 0, lockedUntil: 0 };
entry.count += 1;
if (entry.count >= config.loginMaxAttempts) {
entry.lockedUntil = Date.now() + config.loginLockMinutes * 60 * 1000;
entry.count = 0;
}
loginAttempts.set(ip, entry);
}
function registerLoginOk(ip) {
loginAttempts.delete(ip);
}
// Alte Einträge aufräumen (1×/Stunde)
setInterval(() => {
const now = Date.now();
for (const [ip, entry] of loginAttempts) {
if (entry.lockedUntil <= now && entry.count === 0) loginAttempts.delete(ip);
}
}, 60 * 60 * 1000).unref();
app.post('/api/login', (req, res) => { app.post('/api/login', (req, res) => {
const { name, password } = req.body || {}; const { name, password } = req.body || {};
const ip = req.ip;
const lockSeconds = checkLoginLock(ip);
if (lockSeconds > 0) {
return res
.status(429)
.json({ error: `Zu viele Fehlversuche – bitte in ${lockSeconds} Sekunden erneut versuchen.` });
}
const pw = String(config.appPassword || ''); const pw = String(config.appPassword || '');
if (!pw || !password || password.length !== pw.length) { const valid =
return res.status(401).json({ error: 'Falsches Passwort' }); pw &&
} password &&
const a = crypto.createHash('sha256').update(password).digest(); password.length === pw.length &&
const b = crypto.createHash('sha256').update(pw).digest(); crypto.timingSafeEqual(
if (!crypto.timingSafeEqual(a, b)) { crypto.createHash('sha256').update(String(password)).digest(),
crypto.createHash('sha256').update(pw).digest()
);
if (!valid) {
registerLoginFail(ip);
return res.status(401).json({ error: 'Falsches Passwort' }); return res.status(401).json({ error: 'Falsches Passwort' });
} }
registerLoginOk(ip);
const safeName = String(name || 'Nutzer').slice(0, 40); const safeName = String(name || 'Nutzer').slice(0, 40);
const token = signToken({ n: safeName, exp: Date.now() + 30 * 24 * 3600 * 1000 }); const token = signToken({ n: safeName, exp: Date.now() + 30 * 24 * 3600 * 1000 });
res.cookie('research_auth', token, cookieOptions(req)); res.cookie('research_auth', token, cookieOptions(req));