Login-Sicherheit: Account-Lockout, konfigurierbar, Admin-Verwaltung, .env für JWT-Secret
This commit is contained in:
@@ -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(`
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user