Login-Sicherheit: Account-Lockout, konfigurierbar, Admin-Verwaltung, .env für JWT-Secret

This commit is contained in:
2026-05-28 14:02:32 +02:00
parent 2da72f1246
commit 70d9eeab78
7 changed files with 276 additions and 27 deletions

View File

@@ -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(`

View File

@@ -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;

View File

@@ -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 });
});