Login-Sicherheit: Account-Lockout, konfigurierbar, Admin-Verwaltung, .env für JWT-Secret
This commit is contained in:
4
.env.example
Normal file
4
.env.example
Normal 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
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
.env
|
||||
*.db
|
||||
*.db-bak
|
||||
dist/
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}/>
|
||||
|
||||
Reference in New Issue
Block a user