From 70d9eeab7888c1dc00b6d95fc36a12816e9b4264 Mon Sep 17 00:00:00 2001 From: Dicken Date: Thu, 28 May 2026 14:02:32 +0200 Subject: [PATCH] =?UTF-8?q?Login-Sicherheit:=20Account-Lockout,=20konfigur?= =?UTF-8?q?ierbar,=20Admin-Verwaltung,=20.env=20f=C3=BCr=20JWT-Secret?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 4 ++ .gitignore | 5 ++ backend/src/db.js | 33 ++++++++++ backend/src/routes/admin.js | 47 +++++++++++++ backend/src/routes/auth.js | 128 ++++++++++++++++++++++++++++-------- docker-compose.yml | 3 +- frontend/src/App.jsx | 83 +++++++++++++++++++++++ 7 files changed, 276 insertions(+), 27 deletions(-) create mode 100644 .env.example create mode 100644 .gitignore diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ff346ca --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +# Kopiere diese Datei zu .env und passe die Werte an +# .env wird NICHT ins Git eingecheckt + +JWT_SECRET=ersetze-mich-mit-einem-langen-zufaelligen-string diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aaf8d24 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.env +*.db +*.db-bak +dist/ diff --git a/backend/src/db.js b/backend/src/db.js index 6fcb851..23ad77c 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -246,6 +246,39 @@ if (msgCols2.length && !msgCols2.includes('read_by_recipient')) { db.exec("ALTER TABLE messages ADD COLUMN read_by_recipient INTEGER NOT NULL DEFAULT 0"); } +// ── Login-Sicherheit ────────────────────────────────────────────────────────── +// Spalten für Account-Lockout in users-Tabelle +const userCols = db.prepare("PRAGMA table_info(users)").all().map(r => r.name); +if (!userCols.includes('failed_attempts')) + db.exec("ALTER TABLE users ADD COLUMN failed_attempts INTEGER NOT NULL DEFAULT 0"); +if (!userCols.includes('locked_until')) + db.exec("ALTER TABLE users ADD COLUMN locked_until DATETIME DEFAULT NULL"); +if (!userCols.includes('last_failed_at')) + db.exec("ALTER TABLE users ADD COLUMN last_failed_at DATETIME DEFAULT NULL"); + +// Login-Fehlversuche Log +if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='login_attempts'").get()) { + db.exec(` + CREATE TABLE login_attempts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL, + ip TEXT, + success INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT NULL + ) + `); +} + +// Default-Einstellungen für Login-Schutz +const loginDefaults = [ + ['login_max_attempts', '5'], + ['login_lockout_minutes', '30'], +]; +for (const [k, v] of loginDefaults) { + if (!db.prepare('SELECT value FROM admin_settings WHERE key=?').get(k)) + db.prepare('INSERT INTO admin_settings (key, value) VALUES (?, ?)').run(k, v); +} + // ── Kalkulator-Shares ───────────────────────────────────────────────────────── if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='calculation_shares'").get()) { db.exec(` diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 26de9c7..bba4075 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -93,4 +93,51 @@ router.put('/users/:id/reset-password', authenticate, requireAdmin, (req, res) = res.json({ success: true }); }); +// ── Login-Sicherheit Admin-Endpoints ───────────────────────────────────────── + +const getSetting = k => db.prepare('SELECT value FROM admin_settings WHERE key=?').get(k)?.value; + +// GET gesperrte Konten +router.get('/lockouts', authenticate, requireAdmin, (req, res) => { + const locked = db.prepare(` + SELECT id, username, failed_attempts, locked_until, last_failed_at + FROM users + WHERE locked_until IS NOT NULL + ORDER BY locked_until DESC + `).all(); + res.json(locked); +}); + +// DELETE Sperre aufheben +router.delete('/lockouts/:userId', authenticate, requireAdmin, (req, res) => { + db.prepare('UPDATE users SET failed_attempts=0, locked_until=NULL, last_failed_at=NULL WHERE id=?') + .run(req.params.userId); + res.json({ ok: true }); +}); + +// GET Login-Einstellungen +router.get('/security-settings', authenticate, requireAdmin, (req, res) => { + res.json({ + login_max_attempts: getSetting('login_max_attempts') || '5', + login_lockout_minutes: getSetting('login_lockout_minutes') || '30', + }); +}); + +// PUT Login-Einstellungen +router.put('/security-settings', authenticate, requireAdmin, (req, res) => { + const { login_max_attempts, login_lockout_minutes } = req.body; + for (const [k, v] of [['login_max_attempts', login_max_attempts], ['login_lockout_minutes', login_lockout_minutes]]) { + if (v !== undefined) + db.prepare('INSERT OR REPLACE INTO admin_settings (key, value) VALUES (?, ?)').run(k, String(parseInt(v) || 0)); + } + res.json({ ok: true }); +}); + +// GET Login-Verlauf (letzte 50) +router.get('/login-log', authenticate, requireAdmin, (req, res) => { + res.json(db.prepare(` + SELECT * FROM login_attempts ORDER BY created_at DESC LIMIT 50 + `).all()); +}); + module.exports = router; diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index e1b0a82..6944a64 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -7,12 +7,97 @@ const { authenticate } = require('../middleware/auth'); const router = express.Router(); const SECRET = process.env.JWT_SECRET || 'dev-secret'; -// POST /api/auth/login +const getSetting = k => db.prepare('SELECT value FROM admin_settings WHERE key=?').get(k)?.value; + +// ── Helpers ─────────────────────────────────────────────────────────────────── +function logAttempt(username, ip, success) { + db.prepare("INSERT INTO login_attempts (username, ip, success, created_at) VALUES (?,?,?,datetime('now','localtime'))") + .run(username, ip || 'unknown', success ? 1 : 0); +} + +function getClientIp(req) { + return req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.socket?.remoteAddress || 'unknown'; +} + +function checkAndLock(user) { + const maxAttempts = parseInt(getSetting('login_max_attempts') || '5'); + const lockoutMinutes = parseInt(getSetting('login_lockout_minutes') || '30'); + + // Gesperrt? + if (user.locked_until) { + const until = new Date(user.locked_until.replace(' ', 'T')); + if (until > new Date()) return { locked: true, until }; + // Sperre abgelaufen → zurücksetzen + db.prepare('UPDATE users SET failed_attempts=0, locked_until=NULL, last_failed_at=NULL WHERE id=?').run(user.id); + } + + // Fehlversuch zählen + const newAttempts = (user.failed_attempts || 0) + 1; + if (newAttempts >= maxAttempts) { + db.prepare(`UPDATE users SET failed_attempts=?, locked_until=datetime('now','localtime','+${lockoutMinutes} minutes'), last_failed_at=datetime('now','localtime') WHERE id=?`) + .run(newAttempts, user.id); + const until = new Date(Date.now() + lockoutMinutes * 60 * 1000); + return { locked: true, until, newLock: true }; + } + db.prepare("UPDATE users SET failed_attempts=?, last_failed_at=datetime('now','localtime') WHERE id=?") + .run(newAttempts, user.id); + return { locked: false, remaining: maxAttempts - newAttempts }; +} + +// ── POST /api/auth/login ────────────────────────────────────────────────────── router.post('/login', (req, res) => { const { username, password } = req.body; + const ip = getClientIp(req); + + if (!username || !password) + return res.status(400).json({ error: 'Benutzername und Passwort erforderlich' }); + const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username); - if (!user || !bcrypt.compareSync(password, user.password_hash)) + + // Unbekannter User → trotzdem loggen (kein Timing-Angriff) + if (!user) { + logAttempt(username, ip, false); return res.status(401).json({ error: 'Benutzername oder Passwort falsch' }); + } + + // Gesperrt? + if (user.locked_until) { + const until = new Date(user.locked_until.replace(' ', 'T')); + if (until > new Date()) { + logAttempt(username, ip, false); + const min = Math.ceil((until - Date.now()) / 60000); + return res.status(403).json({ + error: `Konto gesperrt. Noch ${min} Minute${min !== 1 ? 'n' : ''} warten.`, + locked: true, + lockedUntil: until.toISOString(), + }); + } + // Abgelaufen + db.prepare('UPDATE users SET failed_attempts=0, locked_until=NULL, last_failed_at=NULL WHERE id=?').run(user.id); + } + + // Passwort prüfen + const valid = bcrypt.compareSync(password, user.password_hash); + logAttempt(username, ip, valid); + + if (!valid) { + const result = checkAndLock(user); + if (result.locked) { + const min = parseInt(getSetting('login_lockout_minutes') || '30'); + return res.status(403).json({ + error: result.newLock + ? `Zu viele Fehlversuche. Konto für ${min} Minuten gesperrt.` + : `Konto gesperrt. Bitte später versuchen.`, + locked: true, + }); + } + return res.status(401).json({ + error: `Benutzername oder Passwort falsch. Noch ${result.remaining} Versuch${result.remaining !== 1 ? 'e' : ''}.`, + }); + } + + // Erfolgreich → Zähler zurücksetzen + db.prepare('UPDATE users SET failed_attempts=0, locked_until=NULL, last_failed_at=NULL WHERE id=?').run(user.id); const token = jwt.sign( { id: user.id, username: user.username, role: user.role }, @@ -21,7 +106,7 @@ router.post('/login', (req, res) => { res.json({ token, user: { id: user.id, username: user.username, role: user.role } }); }); -// POST /api/auth/change-password +// ── POST /api/auth/change-password ─────────────────────────────────────────── router.post('/change-password', authenticate, (req, res) => { const { currentPassword, newPassword } = req.body; const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id); @@ -34,41 +119,32 @@ router.post('/change-password', authenticate, (req, res) => { res.json({ success: true }); }); -// POST /api/auth/change-username +// ── POST /api/auth/change-username ─────────────────────────────────────────── router.post('/change-username', authenticate, (req, res) => { const { newUsername, password } = req.body; if (!newUsername || newUsername.trim().length < 3) - return res.status(400).json({ error: 'Mindestens 3 Zeichen erforderlich' }); - const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id); + return res.status(400).json({ error: 'Mind. 3 Zeichen' }); + const user = db.prepare('SELECT * FROM users WHERE id=?').get(req.user.id); if (!bcrypt.compareSync(password, user.password_hash)) return res.status(400).json({ error: 'Passwort falsch' }); - const exists = db.prepare('SELECT id FROM users WHERE username = ? AND id != ?').get(newUsername.trim(), req.user.id); + const exists = db.prepare('SELECT id FROM users WHERE username=? AND id!=?').get(newUsername.trim(), req.user.id); if (exists) return res.status(400).json({ error: 'Benutzername bereits vergeben' }); - db.prepare('UPDATE users SET username = ? WHERE id = ?').run(newUsername.trim(), req.user.id); - res.json({ success: true, username: newUsername.trim() }); + db.prepare('UPDATE users SET username=? WHERE id=?').run(newUsername.trim(), req.user.id); + const token = jwt.sign({ id: user.id, username: newUsername.trim(), role: user.role }, SECRET, { expiresIn: '7d' }); + res.json({ token, user: { id: user.id, username: newUsername.trim(), role: user.role } }); }); -// DELETE /api/auth/delete-account – eigenen Account löschen (nicht für letzte Admins) -router.delete('/delete-account', authenticate, (req, res) => { - const { password } = req.body || {}; - const id = req.user.id; - const user = db.prepare('SELECT * FROM users WHERE id=?').get(id); - if (!user) return res.status(404).json({ error: 'Benutzer nicht gefunden' }); - if (password && !bcrypt.compareSync(password, user.password_hash)) +// ── DELETE /api/auth/account ────────────────────────────────────────────────── +router.delete('/account', authenticate, (req, res) => { + const { password } = req.body; + const user = db.prepare('SELECT * FROM users WHERE id=?').get(req.user.id); + if (!password || !bcrypt.compareSync(password, user.password_hash)) return res.status(400).json({ error: 'Passwort falsch' }); - if (!user) return res.status(404).json({ error: 'Benutzer nicht gefunden' }); - // Admin darf sich nicht löschen wenn er der letzte Admin ist if (user.role === 'admin') { const adminCount = db.prepare("SELECT COUNT(*) c FROM users WHERE role='admin'").get().c; - if (adminCount <= 1) return res.status(400).json({ error: 'Letzter Administrator kann sich nicht löschen' }); + if (adminCount <= 1) return res.status(400).json({ error: 'Letzter Admin kann nicht gelöscht werden' }); } - // Alle Daten löschen - db.prepare('DELETE FROM calculations WHERE user_id=?').run(id); - db.prepare('DELETE FROM orders WHERE user_id=?').run(id); - db.prepare('DELETE FROM todos WHERE user_id=?').run(id); - db.prepare('DELETE FROM quick_links WHERE user_id=?').run(id); - db.prepare('DELETE FROM notes WHERE user_id=?').run(id); - db.prepare('DELETE FROM users WHERE id=?').run(id); + db.prepare('DELETE FROM users WHERE id=?').run(req.user.id); res.json({ success: true }); }); diff --git a/docker-compose.yml b/docker-compose.yml index 2f69505..f4ae04c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,8 +5,9 @@ services: dockerfile: Dockerfile container_name: dickendock restart: unless-stopped + env_file: + - .env environment: - - JWT_SECRET=FaRAd7VmOu7Am7H088TkJKpb5cHCWp9ZFQagiugsw5tFDzxz6cVJNKiOTmO - GITEA_URL=http://192.168.1.26:3000 - GITEA_REPO=Dicken/dickendock - TZ=Europe/Berlin diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 1b947a4..e02edde 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -998,6 +998,88 @@ function QuickLinksSettings({ toast }) { } // ── Benutzerverwaltung ──────────────────────────────────────────────────────── +// ── Login-Sicherheit Admin ──────────────────────────────────────────────────── +function SecuritySettingsAdmin({ toast }) { + const [s, setS] = useState({ login_max_attempts:'5', login_lockout_minutes:'30' }); + const [locked, setLocked] = useState([]); + const [busy, setBusy] = useState(false); + + const load = () => { + api('/admin/security-settings').then(setS).catch(()=>{}); + api('/admin/lockouts').then(setLocked).catch(()=>{}); + }; + useEffect(()=>{ load(); },[]); + + const save = async () => { + setBusy(true); + try { await api('/admin/security-settings',{method:'PUT',body:s}); toast('Gespeichert ✓'); } + catch(e) { toast(e.message,'error'); } + setBusy(false); + }; + + const unlock = async uid => { + try { await api(`/admin/lockouts/${uid}`,{method:'DELETE'}); toast('Sperre aufgehoben'); setLocked(p=>p.filter(l=>l.id!==uid)); } + catch(e) { toast(e.message,'error'); } + }; + + const fmtDate = s => { if (!s) return ''; const d = new Date(s.replace(' ','T')); return isNaN(d)?'':d.toLocaleString('de-DE',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}); }; + + return ( +
+ +
+
+ + setS(p=>({...p,login_max_attempts:e.target.value}))} + style={{ ...S.inp, fontSize:15 }}/> +
+
+ + setS(p=>({...p,login_lockout_minutes:e.target.value}))} + style={{ ...S.inp, fontSize:15 }}/> +
+
+ +
+ + 0?`(${locked.length})`:''}`}> + {locked.length===0 ? ( +
+ ✓ Keine gesperrten Konten +
+ ) : locked.map(u => ( +
+
+
+ 🔒 {u.username} +
+
+ {u.failed_attempts} Fehlversuch{u.failed_attempts!==1?'e':''} · + Gesperrt bis: {fmtDate(u.locked_until)} +
+ {u.last_failed_at && ( +
+ Letzter Versuch: {fmtDate(u.last_failed_at)} +
+ )} +
+ +
+ ))} +
+
+ ); +} + function UserManagement({ toast }) { const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); @@ -1458,6 +1540,7 @@ function AdminPanel({ toast, mobile, user }) { {section === 'benutzer' && user?.role==='admin' && (
+