feat: add TOTP 2FA with QR code and manual secret entry

This commit is contained in:
Simon
2026-04-17 22:56:39 +02:00
parent ae789318ba
commit 8f75bc6a10
7 changed files with 959 additions and 26 deletions
+25
View File
@@ -190,10 +190,35 @@ db.exec(`
);
`);
db.exec(`
CREATE TABLE IF NOT EXISTS user_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL UNIQUE,
public_key_jwk TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS group_sender_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id TEXT NOT NULL,
kid TEXT NOT NULL,
recipient_user_id INTEGER NOT NULL,
distributor_user_id INTEGER NOT NULL,
encrypted_key TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (recipient_user_id) REFERENCES users(id),
FOREIGN KEY (distributor_user_id) REFERENCES users(id),
UNIQUE(group_id, kid, recipient_user_id)
);
`);
// Safe migrations
try { db.exec(`ALTER TABLE grades ADD COLUMN type TEXT DEFAULT 'sonstiges'`); } catch {}
try { db.exec(`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'student'`); } catch {}
try { db.exec(`ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'pending'`); } catch {}
try { db.exec(`ALTER TABLE users ADD COLUMN subject TEXT`); } catch {}
try { db.exec(`ALTER TABLE users ADD COLUMN totp_secret TEXT`); } catch {}
try { db.exec(`ALTER TABLE users ADD COLUMN totp_enabled INTEGER DEFAULT 0`); } catch {}
module.exports = db;
+147 -2
View File
@@ -1,6 +1,8 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const rateLimit = require('express-rate-limit');
const { generateSecret, generateURI, verifySync } = require('otplib');
const QRCode = require('qrcode');
const db = require('./db');
const { signToken, requireAuth } = require('./auth');
const { deleteUserFiles } = require('./files');
@@ -56,7 +58,7 @@ router.post('/register', (req, res) => {
});
router.post('/login', loginLimiter, (req, res) => {
const { username, password } = req.body;
const { username, password, totp_token } = req.body;
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
if (!user || !bcrypt.compareSync(password, user.password_hash)) {
return res.status(401).json({ error: 'Falscher Benutzername oder Passwort' });
@@ -73,6 +75,11 @@ router.post('/login', loginLimiter, (req, res) => {
if (user.status !== 'active') {
return res.status(403).json({ error: 'Dein Konto ist nicht aktiv.' });
}
if (user.totp_enabled) {
if (!totp_token) return res.json({ requireTotp: true });
const totpResult = verifySync({ token: String(totp_token), secret: user.totp_secret });
if (!totpResult || !totpResult.valid) return res.status(401).json({ error: 'Ungültiger 2FA-Code' });
}
const token = signToken(user);
res.cookie('token', token, { httpOnly: true, maxAge: 30 * 24 * 60 * 60 * 1000, sameSite: 'lax' });
res.json({ ok: true, username: user.username, role: user.role, subject: user.subject });
@@ -322,7 +329,7 @@ const chatLimiter = rateLimit({
});
const CLASS_ID = 'info1';
const CHAT_MAX_LEN = 500;
const CHAT_MAX_LEN = 2048;
router.get('/chat', requireAuth, (req, res) => {
const after = parseInt(req.query.after, 10) || 0;
@@ -368,6 +375,144 @@ router.delete('/chat/:id', requireAuth, (req, res) => {
res.json({ ok: true });
});
// --- E2EE KEY MANAGEMENT ---
function validPubKeyJwk(str) {
if (typeof str !== 'string' || str.length > 1000) return false;
try {
const j = JSON.parse(str);
return j.kty === 'EC' && j.crv === 'P-256'
&& typeof j.x === 'string' && typeof j.y === 'string'
&& j.x.length <= 64 && j.y.length <= 64
&& !j.d; // reject private key component
} catch { return false; }
}
function validEncryptedKey(str) {
if (typeof str !== 'string' || str.length > 2000) return false;
try {
const p = JSON.parse(str);
return typeof p.iv === 'string' && typeof p.ct === 'string'
&& p.iv.length <= 32 && p.ct.length <= 1800;
} catch { return false; }
}
function validKid(kid) {
return typeof kid === 'string' && /^[a-zA-Z0-9_\-=+/]{8,128}$/.test(kid);
}
router.post('/e2ee/public-key', requireAuth, (req, res) => {
const { public_key_jwk } = req.body;
if (!validPubKeyJwk(public_key_jwk)) return res.status(400).json({ error: 'Ungültiger Public Key' });
db.prepare(`INSERT OR REPLACE INTO user_keys (user_id, public_key_jwk) VALUES (?, ?)`)
.run(req.user.id, public_key_jwk);
res.json({ ok: true });
});
router.get('/e2ee/public-key/:userId', requireAuth, (req, res) => {
const row = db.prepare('SELECT public_key_jwk FROM user_keys WHERE user_id = ?').get(req.params.userId);
if (!row) return res.status(404).json({ error: 'Schlüssel nicht gefunden' });
res.json({ public_key_jwk: row.public_key_jwk });
});
router.get('/e2ee/users', requireAuth, (req, res) => {
const users = db.prepare(`
SELECT u.id, u.username, k.public_key_jwk
FROM users u
JOIN user_keys k ON k.user_id = u.id
WHERE u.status = 'active'
ORDER BY u.id
`).all();
res.json(users);
});
router.get('/e2ee/group-key', requireAuth, (req, res) => {
const { group_id, kid } = req.query;
if (!group_id) return res.status(400).json({ error: 'group_id erforderlich' });
let row;
if (kid) {
row = db.prepare(`
SELECT kid, encrypted_key, distributor_user_id FROM group_sender_keys
WHERE group_id = ? AND kid = ? AND recipient_user_id = ?
`).get(group_id, kid, req.user.id);
} else {
row = db.prepare(`
SELECT kid, encrypted_key, distributor_user_id FROM group_sender_keys
WHERE group_id = ? AND recipient_user_id = ?
ORDER BY created_at DESC LIMIT 1
`).get(group_id, req.user.id);
}
if (!row) return res.status(404).json({ error: 'Schlüssel nicht gefunden' });
res.json(row);
});
router.post('/e2ee/group-keys', requireAuth, (req, res) => {
const { group_id, kid, keys } = req.body;
if (!group_id || !validKid(kid) || !Array.isArray(keys)) {
return res.status(400).json({ error: 'Ungültige Anfrage' });
}
if (keys.length > 500) return res.status(400).json({ error: 'Zu viele Einträge' });
const stmt = db.prepare(`
INSERT OR REPLACE INTO group_sender_keys
(group_id, kid, recipient_user_id, distributor_user_id, encrypted_key)
VALUES (?, ?, ?, ?, ?)
`);
const distribute = db.transaction(entries => {
for (const { user_id, encrypted_key } of entries) {
if (!Number.isInteger(user_id) || !validEncryptedKey(encrypted_key)) continue;
const target = db.prepare(`SELECT id FROM users WHERE id = ? AND status = 'active'`).get(user_id);
if (!target) continue;
stmt.run(group_id, kid, user_id, req.user.id, encrypted_key);
}
});
distribute(keys);
res.json({ ok: true });
});
// --- 2FA ---
router.post('/2fa/setup', requireAuth, async (req, res) => {
const secret = generateSecret();
const user = db.prepare('SELECT username, email FROM users WHERE id = ?').get(req.user.id);
db.prepare('UPDATE users SET totp_secret = ?, totp_enabled = 0 WHERE id = ?').run(secret, req.user.id);
const otpauth = generateURI({ secret, label: user.email, issuer: 'INFO1', type: 'totp' });
try {
const qr = await QRCode.toDataURL(otpauth);
res.json({ otpauth, qr, secret });
} catch {
res.status(500).json({ error: 'QR-Generierung fehlgeschlagen' });
}
});
router.post('/2fa/confirm', requireAuth, loginLimiter, (req, res) => {
const { token } = req.body;
const user = db.prepare('SELECT totp_secret, totp_enabled FROM users WHERE id = ?').get(req.user.id);
if (!user.totp_secret) return res.status(400).json({ error: '2FA-Setup nicht gestartet' });
const result = verifySync({ token: String(token), secret: user.totp_secret });
if (!result || !result.valid) return res.status(401).json({ error: 'Ungültiger Code' });
db.prepare('UPDATE users SET totp_enabled = 1 WHERE id = ?').run(req.user.id);
res.json({ ok: true });
});
router.post('/2fa/disable', requireAuth, loginLimiter, (req, res) => {
const { password, token } = req.body;
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
if (!bcrypt.compareSync(password, user.password_hash)) {
return res.status(401).json({ error: 'Passwort falsch' });
}
if (!user.totp_enabled || !user.totp_secret) {
return res.status(400).json({ error: '2FA ist nicht aktiv' });
}
const disableResult = verifySync({ token: String(token), secret: user.totp_secret });
if (!disableResult || !disableResult.valid) return res.status(401).json({ error: 'Ungültiger 2FA-Code' });
db.prepare('UPDATE users SET totp_secret = NULL, totp_enabled = 0 WHERE id = ?').run(req.user.id);
res.json({ ok: true });
});
router.get('/2fa/status', requireAuth, (req, res) => {
const user = db.prepare('SELECT totp_enabled FROM users WHERE id = ?').get(req.user.id);
res.json({ enabled: !!user.totp_enabled });
});
// --- PERSONAL CRUD ---
function crudRoutes(path, table, fields) {
router.get(`/${path}`, requireAuth, (req, res) => {