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

4
.env.example Normal file
View File

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

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
.env
*.db
*.db-bak
dist/

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' });
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);
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() });
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 });
});

View File

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

View File

@@ -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 (
<div>
<Sec title="LOGIN-SCHUTZ EINSTELLUNGEN">
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:10, marginBottom:12 }}>
<div>
<label style={{ ...S.head, display:'block', marginBottom:4 }}>MAX. FEHLVERSUCHE</label>
<input type="number" min="1" max="20" value={s.login_max_attempts}
onChange={e=>setS(p=>({...p,login_max_attempts:e.target.value}))}
style={{ ...S.inp, fontSize:15 }}/>
</div>
<div>
<label style={{ ...S.head, display:'block', marginBottom:4 }}>SPERRZEIT (MINUTEN)</label>
<input type="number" min="1" max="1440" value={s.login_lockout_minutes}
onChange={e=>setS(p=>({...p,login_lockout_minutes:e.target.value}))}
style={{ ...S.inp, fontSize:15 }}/>
</div>
</div>
<button onClick={save} disabled={busy}
style={{ ...S.btn('#4ecdc4'), width:'100%', textAlign:'center', padding:'10px 0', opacity:busy?0.5:1 }}>
{busy?'…':'✓ Speichern'}
</button>
</Sec>
<Sec title={`GESPERRTE KONTEN ${locked.length>0?`(${locked.length})`:''}`}>
{locked.length===0 ? (
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:11 }}>
Keine gesperrten Konten
</div>
) : locked.map(u => (
<div key={u.id} style={{ display:'flex', alignItems:'center', gap:10,
padding:'10px 0', borderBottom:'1px solid rgba(255,255,255,0.05)' }}>
<div style={{ flex:1, minWidth:0 }}>
<div style={{ color:'#ff6b9d', fontFamily:'monospace', fontSize:13, fontWeight:700 }}>
🔒 {u.username}
</div>
<div style={{ color:'rgba(255,255,255,0.35)', fontFamily:'monospace', fontSize:10, marginTop:3, lineHeight:1.7 }}>
{u.failed_attempts} Fehlversuch{u.failed_attempts!==1?'e':''} ·
Gesperrt bis: {fmtDate(u.locked_until)}
</div>
{u.last_failed_at && (
<div style={{ color:'rgba(255,255,255,0.2)', fontFamily:'monospace', fontSize:9 }}>
Letzter Versuch: {fmtDate(u.last_failed_at)}
</div>
)}
</div>
<button onClick={()=>unlock(u.id)}
style={{ ...S.btn('#4ecdc4', true), flexShrink:0, fontSize:11 }}>
🔓 Entsperren
</button>
</div>
))}
</Sec>
</div>
);
}
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' && (
<div>
<SecuritySettingsAdmin toast={toast}/>
<UserManagement toast={toast}/>
<Sec title="DATEI-MANAGER LIMITS">
<DateiSettings toast={toast}/>