Files
vendoo/app/core/identity/identity-store.mjs
T
Masterluke77andGitHub 40071f718e Vendoo 1.43.0 – Production Update
Manifestbasierter Production-Updater 3.3, Modulnavigation, optionales Produktbild Studio, Listing-Studio-Aktionsleiste sowie plattformübergreifende Windows-/Linux-Release-Gates.
2026-07-10 13:16:21 +02:00

419 lines
17 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;
}
export function listInvitations(limit = 100) {
const safeLimit = Math.max(1, Math.min(500, Number(limit) || 100));
return db.prepare(`SELECT id, email, role, used, expires_at, created_by, created_at,
CASE
WHEN used = 1 THEN 'revoked_or_used'
WHEN expires_at <= datetime('now') THEN 'expired'
ELSE 'pending'
END AS status
FROM auth_tokens
WHERE type = 'invite'
ORDER BY created_at DESC
LIMIT ?`).all(safeLimit);
}
export function getInvitation(id) {
return db.prepare(`SELECT id, email, role, used, expires_at, created_by, created_at,
CASE
WHEN used = 1 THEN 'revoked_or_used'
WHEN expires_at <= datetime('now') THEN 'expired'
ELSE 'pending'
END AS status
FROM auth_tokens WHERE id = ? AND type = 'invite'`).get(Number(id));
}
export function revokeInvitation(id) {
const invitation = getInvitation(id);
if (!invitation) return null;
if (invitation.status === 'pending') db.prepare('UPDATE auth_tokens SET used = 1 WHERE id = ? AND type = ?').run(Number(id), 'invite');
return { ...invitation, status: invitation.status === 'pending' ? 'revoked' : invitation.status, used: 1 };
}
// --- 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);