387 lines
16 KiB
JavaScript
387 lines
16 KiB
JavaScript
import { db } from '../../../lib/db.mjs';
|
|
import { createHash, randomBytes, scryptSync, timingSafeEqual } from 'crypto';
|
|
|
|
// --- Schema ---
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
email TEXT UNIQUE NOT NULL,
|
|
name TEXT NOT NULL DEFAULT '',
|
|
password_hash TEXT,
|
|
role TEXT NOT NULL DEFAULT 'editor',
|
|
active INTEGER NOT NULL DEFAULT 1,
|
|
avatar_color TEXT,
|
|
last_active_at TEXT,
|
|
created_at TEXT DEFAULT (datetime('now')),
|
|
invited_by INTEGER,
|
|
FOREIGN KEY (invited_by) REFERENCES users(id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL,
|
|
token_hash TEXT UNIQUE NOT NULL,
|
|
ip TEXT,
|
|
user_agent TEXT,
|
|
expires_at TEXT NOT NULL,
|
|
created_at TEXT DEFAULT (datetime('now')),
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS auth_tokens (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
email TEXT NOT NULL,
|
|
token_hash TEXT UNIQUE NOT NULL,
|
|
type TEXT NOT NULL DEFAULT 'login',
|
|
role TEXT DEFAULT 'editor',
|
|
used INTEGER NOT NULL DEFAULT 0,
|
|
expires_at TEXT NOT NULL,
|
|
created_by INTEGER,
|
|
created_at TEXT DEFAULT (datetime('now')),
|
|
FOREIGN KEY (created_by) REFERENCES users(id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS audit_log (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER,
|
|
user_email TEXT,
|
|
action TEXT NOT NULL,
|
|
target_type TEXT,
|
|
target_id TEXT,
|
|
details TEXT,
|
|
ip TEXT,
|
|
created_at TEXT DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS login_history (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL,
|
|
ip TEXT,
|
|
user_agent TEXT,
|
|
success INTEGER NOT NULL DEFAULT 1,
|
|
created_at TEXT DEFAULT (datetime('now')),
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
);
|
|
`);
|
|
|
|
// Migration: add password_hash column if missing
|
|
try { db.exec("ALTER TABLE users ADD COLUMN password_hash TEXT"); } catch {}
|
|
try { db.exec("ALTER TABLE users ADD COLUMN failed_login_count INTEGER NOT NULL DEFAULT 0"); } catch {}
|
|
try { db.exec("ALTER TABLE users ADD COLUMN locked_until TEXT"); } catch {}
|
|
try { db.exec("ALTER TABLE users ADD COLUMN password_changed_at TEXT"); } catch {}
|
|
try { db.exec("ALTER TABLE sessions ADD COLUMN last_seen_at TEXT"); } catch {}
|
|
try { db.exec("ALTER TABLE sessions ADD COLUMN revoked_at TEXT"); } catch {}
|
|
try { db.exec("UPDATE sessions SET last_seen_at = COALESCE(last_seen_at, created_at)"); } catch {}
|
|
|
|
const AVATAR_COLORS = [
|
|
'#4a7c59', '#2d6a9f', '#8b5a3c', '#6b4c8a', '#c4713b',
|
|
'#3a8c7a', '#7c4d6e', '#5a7c3a', '#8c5a5a', '#3a6b8c',
|
|
];
|
|
|
|
const SESSION_DURATION_MS = Math.max(1, Number(process.env.VENDOO_SESSION_DAYS || 3)) * 24 * 60 * 60 * 1000;
|
|
const SESSION_IDLE_MS = Math.max(10, Number(process.env.VENDOO_SESSION_IDLE_MINUTES || 120)) * 60 * 1000;
|
|
const SESSION_TOUCH_INTERVAL_MS = 60 * 1000;
|
|
const LOGIN_TOKEN_EXPIRY_MIN = 15;
|
|
const INVITE_TOKEN_EXPIRY_HOURS = 48;
|
|
|
|
function hashToken(token) {
|
|
return createHash('sha256').update(token).digest('hex');
|
|
}
|
|
|
|
function generateToken() {
|
|
return randomBytes(32).toString('hex');
|
|
}
|
|
|
|
function randomAvatarColor() {
|
|
return AVATAR_COLORS[Math.floor(Math.random() * AVATAR_COLORS.length)];
|
|
}
|
|
|
|
// --- Password Hashing (scrypt) ---
|
|
export function hashPassword(password) {
|
|
const salt = randomBytes(16).toString('hex');
|
|
const hash = scryptSync(password, salt, 64).toString('hex');
|
|
return `${salt}:${hash}`;
|
|
}
|
|
|
|
export function verifyPassword(password, storedHash) {
|
|
if (!storedHash) return false;
|
|
const [salt, hash] = storedHash.split(':');
|
|
if (!salt || !hash) return false;
|
|
const hashBuf = Buffer.from(hash, 'hex');
|
|
const supplied = scryptSync(password, salt, 64);
|
|
return timingSafeEqual(hashBuf, supplied);
|
|
}
|
|
|
|
// --- Users ---
|
|
export function createUser(email, name, role = 'editor', invitedBy = null, password = null) {
|
|
role = ['admin', 'manager', 'editor', 'publisher', 'viewer'].includes(role) ? role : 'editor';
|
|
const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
|
|
if (existing) throw new Error('User existiert bereits');
|
|
const color = randomAvatarColor();
|
|
const pwHash = password ? hashPassword(password) : null;
|
|
const r = db.prepare('INSERT INTO users (email, name, role, avatar_color, invited_by, password_hash) VALUES (?, ?, ?, ?, ?, ?)').run(email, name, role, color, invitedBy, pwHash);
|
|
return getUser(r.lastInsertRowid);
|
|
}
|
|
|
|
export function setPassword(userId, password) {
|
|
const pwHash = hashPassword(password);
|
|
db.prepare("UPDATE users SET password_hash = ?, password_changed_at = datetime('now'), failed_login_count = 0, locked_until = NULL WHERE id = ?").run(pwHash, userId);
|
|
destroyUserSessions(userId);
|
|
}
|
|
|
|
export function validatePasswordStrength(password) {
|
|
const value = String(password || '');
|
|
const problems = [];
|
|
if (value.length < 10) problems.push('mindestens 10 Zeichen');
|
|
if (!/[a-z]/.test(value)) problems.push('einen Kleinbuchstaben');
|
|
if (!/[A-Z]/.test(value)) problems.push('einen Großbuchstaben');
|
|
if (!/\d/.test(value)) problems.push('eine Zahl');
|
|
if (!/[^A-Za-z0-9]/.test(value)) problems.push('ein Sonderzeichen');
|
|
return { valid: problems.length === 0, problems };
|
|
}
|
|
|
|
export function registerFailedLogin(userId, maxAttempts = 8, lockMinutes = 15) {
|
|
if (!userId) return null;
|
|
const user = getUser(userId);
|
|
if (!user) return null;
|
|
const count = Number(user.failed_login_count || 0) + 1;
|
|
if (count >= maxAttempts) {
|
|
const lockedUntil = new Date(Date.now() + lockMinutes * 60 * 1000).toISOString();
|
|
db.prepare('UPDATE users SET failed_login_count = 0, locked_until = ? WHERE id = ?').run(lockedUntil, userId);
|
|
destroyUserSessions(userId);
|
|
return { locked: true, lockedUntil };
|
|
}
|
|
db.prepare('UPDATE users SET failed_login_count = ? WHERE id = ?').run(count, userId);
|
|
return { locked: false, remaining: Math.max(0, maxAttempts - count) };
|
|
}
|
|
|
|
export function clearFailedLogins(userId) {
|
|
if (!userId) return;
|
|
db.prepare('UPDATE users SET failed_login_count = 0, locked_until = NULL WHERE id = ?').run(userId);
|
|
}
|
|
|
|
export function isUserLocked(user) {
|
|
if (!user?.locked_until) return false;
|
|
const until = Date.parse(user.locked_until);
|
|
if (!Number.isFinite(until) || until <= Date.now()) {
|
|
clearFailedLogins(user.id);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export function getUser(id) {
|
|
return db.prepare('SELECT * FROM users WHERE id = ?').get(id);
|
|
}
|
|
|
|
export function getUserByEmail(email) {
|
|
return db.prepare('SELECT * FROM users WHERE email = ? COLLATE NOCASE').get(email);
|
|
}
|
|
|
|
export function getUsers() {
|
|
return db.prepare("SELECT id, email, name, role, active, avatar_color, last_active_at, created_at, locked_until, failed_login_count, CASE WHEN password_hash IS NOT NULL AND password_hash != '' THEN 1 ELSE 0 END as has_password FROM users ORDER BY created_at").all();
|
|
}
|
|
|
|
export function updateUser(id, data) {
|
|
const allowed = ['name', 'role', 'active', 'avatar_color'];
|
|
const fields = [], values = [];
|
|
for (const [k, rawValue] of Object.entries(data)) {
|
|
if (!allowed.includes(k) || rawValue === undefined) continue;
|
|
const v = k === 'role' ? (['admin', 'manager', 'editor', 'publisher', 'viewer'].includes(rawValue) ? rawValue : 'viewer') : rawValue;
|
|
fields.push(`${k} = ?`); values.push(v);
|
|
}
|
|
if (!fields.length) return getUsers().find(user => Number(user.id) === Number(id)) || null;
|
|
values.push(id);
|
|
db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...values);
|
|
return getUsers().find(user => Number(user.id) === Number(id)) || null;
|
|
}
|
|
|
|
export function deleteUser(id) {
|
|
db.prepare('DELETE FROM sessions WHERE user_id = ?').run(id);
|
|
db.prepare('DELETE FROM users WHERE id = ?').run(id);
|
|
}
|
|
|
|
export function touchUserActivity(userId) {
|
|
db.prepare("UPDATE users SET last_active_at = datetime('now') WHERE id = ?").run(userId);
|
|
}
|
|
|
|
export function getUserCount() {
|
|
return db.prepare('SELECT COUNT(*) as count FROM users').get().count;
|
|
}
|
|
|
|
// --- Sessions ---
|
|
export function createSession(userId, ip, userAgent) {
|
|
const token = generateToken();
|
|
const hash = hashToken(token);
|
|
const now = new Date();
|
|
const expiresAt = new Date(now.getTime() + SESSION_DURATION_MS).toISOString();
|
|
const lastSeenAt = now.toISOString();
|
|
db.prepare('INSERT INTO sessions (user_id, token_hash, ip, user_agent, expires_at, last_seen_at, revoked_at) VALUES (?, ?, ?, ?, ?, ?, NULL)').run(userId, hash, ip, userAgent || '', expiresAt, lastSeenAt);
|
|
return { token, expiresAt };
|
|
}
|
|
|
|
export function validateSession(token) {
|
|
if (!token) return null;
|
|
const hash = hashToken(token);
|
|
const session = db.prepare("SELECT s.*, u.email, u.name, u.role, u.active, u.avatar_color, u.locked_until FROM sessions s JOIN users u ON s.user_id = u.id WHERE s.token_hash = ? AND s.revoked_at IS NULL AND s.expires_at > datetime('now')").get(hash);
|
|
if (!session || !session.active || isUserLocked(session)) return null;
|
|
const lastSeen = Date.parse(session.last_seen_at || session.created_at || 0);
|
|
if (Number.isFinite(lastSeen) && Date.now() - lastSeen > SESSION_IDLE_MS) {
|
|
db.prepare("UPDATE sessions SET revoked_at = datetime('now') WHERE id = ?").run(session.id);
|
|
return null;
|
|
}
|
|
if (!Number.isFinite(lastSeen) || Date.now() - lastSeen >= SESSION_TOUCH_INTERVAL_MS) {
|
|
const now = new Date().toISOString();
|
|
db.prepare('UPDATE sessions SET last_seen_at = ? WHERE id = ?').run(now, session.id);
|
|
session.last_seen_at = now;
|
|
touchUserActivity(session.user_id);
|
|
}
|
|
return session;
|
|
}
|
|
|
|
export function destroySession(token) {
|
|
const hash = hashToken(token);
|
|
db.prepare("UPDATE sessions SET revoked_at = datetime('now') WHERE token_hash = ?").run(hash);
|
|
}
|
|
|
|
export function destroySessionById(id) {
|
|
db.prepare("UPDATE sessions SET revoked_at = datetime('now') WHERE id = ?").run(id);
|
|
}
|
|
|
|
export function getSessionById(id) {
|
|
return db.prepare("SELECT s.id, s.user_id, s.ip, s.user_agent, s.created_at, s.last_seen_at, s.expires_at, s.revoked_at, u.email, u.name, u.role FROM sessions s JOIN users u ON s.user_id = u.id WHERE s.id = ?").get(id);
|
|
}
|
|
|
|
export function destroyOwnedSessionById(userId, id) {
|
|
const result = db.prepare("UPDATE sessions SET revoked_at = datetime('now') WHERE id = ? AND user_id = ? AND revoked_at IS NULL").run(id, userId);
|
|
return Number(result.changes || 0);
|
|
}
|
|
|
|
export function destroyUserSessions(userId) {
|
|
db.prepare("UPDATE sessions SET revoked_at = datetime('now') WHERE user_id = ? AND revoked_at IS NULL").run(userId);
|
|
}
|
|
|
|
export function getUserSessions(userId) {
|
|
return db.prepare("SELECT id, ip, user_agent, created_at, last_seen_at, expires_at FROM sessions WHERE user_id = ? AND revoked_at IS NULL AND expires_at > datetime('now') ORDER BY created_at DESC").all(userId);
|
|
}
|
|
|
|
export function getAllActiveSessions() {
|
|
return db.prepare("SELECT s.id, s.user_id, s.ip, s.user_agent, s.created_at, s.last_seen_at, s.expires_at, u.email, u.name, u.role FROM sessions s JOIN users u ON s.user_id = u.id WHERE s.revoked_at IS NULL AND s.expires_at > datetime('now') ORDER BY s.created_at DESC").all();
|
|
}
|
|
|
|
// clean expired sessions periodically
|
|
export function cleanExpiredSessions() {
|
|
db.prepare("DELETE FROM sessions WHERE expires_at <= datetime('now') OR (revoked_at IS NOT NULL AND revoked_at <= datetime('now', '-7 days'))").run();
|
|
}
|
|
|
|
// --- Auth Tokens (Magic Links) ---
|
|
export function createAuthToken(email, type = 'login', role = 'editor', createdBy = null) {
|
|
const token = generateToken();
|
|
const hash = hashToken(token);
|
|
const minutes = type === 'invite' ? INVITE_TOKEN_EXPIRY_HOURS * 60 : LOGIN_TOKEN_EXPIRY_MIN;
|
|
const expiresAt = new Date(Date.now() + minutes * 60 * 1000).toISOString();
|
|
|
|
// invalidate previous unused tokens of same type for same email
|
|
db.prepare("UPDATE auth_tokens SET used = 1 WHERE email = ? COLLATE NOCASE AND type = ? AND used = 0").run(email, type);
|
|
|
|
db.prepare('INSERT INTO auth_tokens (email, token_hash, type, role, expires_at, created_by) VALUES (?, ?, ?, ?, ?, ?)').run(email, hash, type, role, expiresAt, createdBy);
|
|
return { token, expiresAt };
|
|
}
|
|
|
|
export function validateAuthToken(token) {
|
|
const hash = hashToken(token);
|
|
const row = db.prepare("SELECT * FROM auth_tokens WHERE token_hash = ? AND used = 0 AND expires_at > datetime('now')").get(hash);
|
|
if (!row) return null;
|
|
db.prepare('UPDATE auth_tokens SET used = 1 WHERE id = ?').run(row.id);
|
|
return row;
|
|
}
|
|
|
|
// --- Rate Limiting ---
|
|
const rateLimits = new Map();
|
|
|
|
export function checkRateLimit(key, maxAttempts = 5, windowMs = 15 * 60 * 1000) {
|
|
const now = Date.now();
|
|
const entry = rateLimits.get(key);
|
|
if (!entry) {
|
|
rateLimits.set(key, { count: 1, firstAttempt: now });
|
|
return { allowed: true, remaining: maxAttempts - 1 };
|
|
}
|
|
if (now - entry.firstAttempt > windowMs) {
|
|
rateLimits.set(key, { count: 1, firstAttempt: now });
|
|
return { allowed: true, remaining: maxAttempts - 1 };
|
|
}
|
|
entry.count++;
|
|
if (entry.count > maxAttempts) {
|
|
const retryAfter = Math.ceil((entry.firstAttempt + windowMs - now) / 1000);
|
|
return { allowed: false, remaining: 0, retryAfter };
|
|
}
|
|
return { allowed: true, remaining: maxAttempts - entry.count };
|
|
}
|
|
|
|
// --- CSRF ---
|
|
export function generateCsrfToken() {
|
|
return randomBytes(24).toString('hex');
|
|
}
|
|
|
|
// --- Audit Log ---
|
|
export function auditLog(userId, userEmail, action, targetType = null, targetId = null, details = null, ip = null) {
|
|
db.prepare('INSERT INTO audit_log (user_id, user_email, action, target_type, target_id, details, ip) VALUES (?, ?, ?, ?, ?, ?, ?)').run(userId, userEmail, action, targetType, targetId, details, ip);
|
|
}
|
|
|
|
export function getAuditLog(limit = 100, offset = 0) {
|
|
return db.prepare('SELECT * FROM audit_log ORDER BY created_at DESC LIMIT ? OFFSET ?').all(limit, offset);
|
|
}
|
|
|
|
export function searchAuditLog({ limit = 100, offset = 0, userId = null, action = '', from = '', to = '', query = '' } = {}) {
|
|
const clauses = [];
|
|
const values = [];
|
|
if (userId) { clauses.push('user_id = ?'); values.push(Number(userId)); }
|
|
if (action) { clauses.push('action LIKE ?'); values.push(`%${String(action).slice(0, 80)}%`); }
|
|
if (from) { clauses.push('created_at >= ?'); values.push(String(from).slice(0, 30)); }
|
|
if (to) { clauses.push('created_at <= ?'); values.push(String(to).slice(0, 30)); }
|
|
if (query) {
|
|
const q = `%${String(query).slice(0, 120)}%`;
|
|
clauses.push('(user_email LIKE ? OR action LIKE ? OR target_type LIKE ? OR target_id LIKE ? OR details LIKE ? OR ip LIKE ?)');
|
|
values.push(q, q, q, q, q, q);
|
|
}
|
|
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
|
|
const safeLimit = Math.max(1, Math.min(1000, Number(limit) || 100));
|
|
const safeOffset = Math.max(0, Number(offset) || 0);
|
|
const rows = db.prepare(`SELECT * FROM audit_log ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`).all(...values, safeLimit, safeOffset);
|
|
const total = db.prepare(`SELECT COUNT(*) AS count FROM audit_log ${where}`).get(...values)?.count || 0;
|
|
return { rows, total, limit: safeLimit, offset: safeOffset };
|
|
}
|
|
|
|
export function getAuditLogForUser(userId, limit = 50) {
|
|
return db.prepare('SELECT * FROM audit_log WHERE user_id = ? ORDER BY created_at DESC LIMIT ?').all(userId, limit);
|
|
}
|
|
|
|
// --- Login History ---
|
|
export function recordLogin(userId, ip, userAgent, success = true) {
|
|
db.prepare('INSERT INTO login_history (user_id, ip, user_agent, success) VALUES (?, ?, ?, ?)').run(userId, ip, userAgent || '', success ? 1 : 0);
|
|
}
|
|
|
|
export function getLoginHistory(userId, limit = 20) {
|
|
return db.prepare('SELECT * FROM login_history WHERE user_id = ? ORDER BY created_at DESC LIMIT ?').all(userId, limit);
|
|
}
|
|
|
|
export function getRecentFailedLogins(email, windowMinutes = 15) {
|
|
const user = getUserByEmail(email);
|
|
if (!user) return 0;
|
|
const r = db.prepare("SELECT COUNT(*) as count FROM login_history WHERE user_id = ? AND success = 0 AND created_at > datetime('now', ?)").get(user.id, `-${windowMinutes} minutes`);
|
|
return r.count;
|
|
}
|
|
|
|
// --- Setup: ensure at least one admin exists ---
|
|
export function ensureAdminExists() {
|
|
const count = getUserCount();
|
|
return count === 0;
|
|
}
|
|
|
|
export function isSetupMode() {
|
|
return getUserCount() === 0;
|
|
}
|
|
|
|
// Clean expired sessions every 10 minutes
|
|
setInterval(cleanExpiredSessions, 10 * 60 * 1000);
|