feat: add TOTP 2FA with QR code and manual secret entry
This commit is contained in:
@@ -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
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user