diff --git a/lib/migrate-multiuser.js b/lib/migrate-multiuser.js index a26740c..54a95a1 100644 --- a/lib/migrate-multiuser.js +++ b/lib/migrate-multiuser.js @@ -79,6 +79,12 @@ async function runMigration({ db, dbAll, dbGet, dbRun }) { ) `); await exec('CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id)'); + // Impersonation support: the admin's session keeps its token but switches + // user_id to the target, storing the original admin in impersonator_id so the + // admin can switch back. Added after the fact for existing installs. + if (!(await hasColumn('sessions', 'impersonator_id'))) { + await exec('ALTER TABLE sessions ADD COLUMN impersonator_id INTEGER'); + } // 2. Ensure admin user (idempotent) ----------------------------------- let admin = await dbGet('SELECT id, password_hash FROM users WHERE username = ?', [ADMIN_USERNAME]); diff --git a/server.js b/server.js index a17cf7b..de675b3 100644 --- a/server.js +++ b/server.js @@ -158,9 +158,16 @@ app.set('views', path.join(__dirname, 'views')); app.use(async (req, res, next) => { try { const cookies = parseCookies(req.headers.cookie); - const user = await loadSessionUser(cookies[SESSION_COOKIE]); + const token = cookies[SESSION_COOKIE]; + const user = await loadSessionUser(token); req.user = user; + req.sessionToken = token; res.locals.user = user; + // impersonator is set when an admin is acting as another user; the header + // shows a banner and the "switch back" action uses it. + const impersonator = user && user.impersonator ? user.impersonator : null; + req.impersonator = impersonator; + res.locals.impersonator = impersonator; // The header highlights the section you are in; it needs the current path. res.locals.pfad = req.path; // Warm this user's cfg rows before anything reads them: config.get() is @@ -681,12 +688,22 @@ async function destroySession(token) { // enforces the server-side absolute session expiry: a cookie is only valid for // SESSION_MAX_AGE after the last activity, regardless of the client-side // maxAge (which a client can tamper with). Expired rows are deleted. +// +// When the session is an impersonation (an admin "logging in as" a user), the +// returned user is the *target*; `impersonator` carries the original admin so +// the app can show a banner and offer a "switch back" action. While +// impersonating, the effective user has no admin rights (see requireAdmin) — +// the admin is debugging the user's account, not escalating. async function loadSessionUser(token) { if (!token) return null; const row = await dbGet( `SELECT u.id AS id, u.username AS username, u.is_admin AS is_admin, - s.created_at AS created_at, s.last_seen AS last_seen - FROM sessions s JOIN users u ON u.id = s.user_id + s.created_at AS created_at, s.last_seen AS last_seen, + s.impersonator_id AS impersonator_id, + i.username AS impersonator_username, i.is_admin AS impersonator_is_admin + FROM sessions s + JOIN users u ON u.id = s.user_id + LEFT JOIN users i ON i.id = s.impersonator_id WHERE s.token = ?`, [token] ); @@ -698,7 +715,11 @@ async function loadSessionUser(token) { return null; } await dbRun('UPDATE sessions SET last_seen = CURRENT_TIMESTAMP WHERE token = ?', [token]).catch(() => {}); - return { id: row.id, username: row.username, is_admin: !!row.is_admin }; + const user = { id: row.id, username: row.username, is_admin: !!row.is_admin }; + if (row.impersonator_id) { + user.impersonator = { id: row.impersonator_id, username: row.impersonator_username, is_admin: !!row.impersonator_is_admin }; + } + return user; } // Find a user by username + password (login check). Returns the user object or null. @@ -1161,10 +1182,27 @@ async function initializeDatabase() { user_id INTEGER NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_seen DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + impersonator_id INTEGER, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (impersonator_id) REFERENCES users(id) ON DELETE SET NULL ) `); await exec('CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id)'); + // Audit trail for admin actions that act on another user's account (today: + // impersonation start/stop). Persistent so a later review can reconstruct who + // acted as whom and when — a plain console.log would not survive a restart. + await exec(` + CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + actor_user_id INTEGER, + target_user_id INTEGER, + action TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL, + FOREIGN KEY (target_user_id) REFERENCES users(id) ON DELETE SET NULL + ) + `); + await exec('CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at)'); // Upgrade legacy single-user installs BEFORE the per-user CREATE/INDEX // statements below: the migration adds the user_id column to every existing @@ -3473,7 +3511,10 @@ initializeDatabase().then(async () => { // alle seine Daten (Bewerbungen, E-Mails, Termine, Chat, Dateien liegen in // data/// und müssen separat entfernt werden — siehe unten). function requireAdmin(req, res, next) { - if (req.user && req.user.is_admin) return next(); + // Admin rights are suspended while impersonating another user: the admin is + // reviewing that user's account, not exercising admin powers. This also + // keeps the impersonated session from reaching admin endpoints at all. + if (req.user && req.user.is_admin && !req.impersonator) return next(); if (req.path.startsWith('/admin/') || req.xhr || (req.get('accept') || '').includes('application/json')) { return res.status(403).send('Zugriff verweigert – nur für Administratoren.'); } @@ -3552,6 +3593,97 @@ initializeDatabase().then(async () => { } }); + // --- Impersonation: admin "logs in as" a user -------------------------- + // Best practice (Django/Flask-impersonate style): the admin's own session + // keeps its cookie token, but its user_id switches to the target and the + // original admin is preserved in sessions.impersonator_id. Everything the app + // does (DB queries via uid(), per-user config, file dirs) then runs as the + // target — the admin sees exactly the user's account. Admin rights are + // suspended while impersonating (requireAdmin), and a banner + "switch back" + // action are always one click away. Every start/stop is recorded in audit_log. + + async function auditLog(actorId, targetId, action) { + await dbRun( + 'INSERT INTO audit_log (actor_user_id, target_user_id, action) VALUES (?, ?, ?)', + [actorId || null, targetId || null, action] + ).catch((e) => console.error('audit_log write failed:', e.message)); + } + + // Start impersonating a user. Admin-only, and never while already + // impersonating (no nesting — switch back first). + app.post('/admin/users/:id/impersonate', requireAdmin, async (req, res) => { + try { + const targetId = Number(req.params.id); + if (!targetId || targetId === Number(req.user.id)) { + return res.status(400).send('Man kann sich nicht selbst imitieren.'); + } + const target = await dbGet('SELECT id, username FROM users WHERE id = ?', [targetId]); + if (!target) return res.status(404).send('Benutzer nicht gefunden.'); + const token = req.sessionToken; + if (!token) return res.status(400).send('Keine Sitzung.'); + await dbRun( + 'UPDATE sessions SET user_id = ?, impersonator_id = ?, last_seen = CURRENT_TIMESTAMP WHERE token = ?', + [target.id, req.user.id, token] + ); + await auditLog(req.user.id, target.id, `impersonate_start → ${target.username}`); + console.log(`Impersonation: Admin #${req.user.id} (${req.user.username}) → User #${target.id} (${target.username})`); + res.redirect('/'); + } catch (error) { + console.error('Admin impersonate error:', error); + res.status(500).send('Serverfehler'); + } + }); + + // Switch back to the original admin. This is intentionally NOT requireAdmin: + // it runs while impersonating, when req.user is the target (non-admin). The + // guard is the session's impersonator_id — only an actual impersonation can + // stop one, so a normal user session (impersonator_id NULL) cannot use it. + app.post('/admin/impersonate/stop', async (req, res) => { + try { + const token = req.sessionToken; + if (!token) return res.redirect('/'); + const row = await dbGet( + 'SELECT user_id, impersonator_id FROM sessions WHERE token = ?', + [token] + ); + if (!row || !row.impersonator_id) { + return res.status(403).send('Keine aktive Impersonation.'); + } + const adminId = row.impersonator_id; + const targetId = row.user_id; + await dbRun( + 'UPDATE sessions SET user_id = ?, impersonator_id = NULL, last_seen = CURRENT_TIMESTAMP WHERE token = ?', + [adminId, token] + ); + await auditLog(adminId, targetId, 'impersonate_stop'); + console.log(`Impersonation: Admin #${adminId} switched back from User #${targetId}`); + res.redirect('/admin'); + } catch (error) { + console.error('Admin impersonate stop error:', error); + res.status(500).send('Serverfehler'); + } + }); + + // Recent impersonation audit entries for the admin dashboard. + app.get('/admin/audit/impersonations', requireAdmin, async (req, res) => { + try { + const rows = await dbAll( + `SELECT a.action, a.created_at, + au.username AS actor, tu.username AS target + FROM audit_log a + LEFT JOIN users au ON au.id = a.actor_user_id + LEFT JOIN users tu ON tu.id = a.target_user_id + WHERE a.action LIKE 'impersonate_%' + ORDER BY a.created_at DESC + LIMIT 25` + ); + res.json({ items: rows }); + } catch (error) { + console.error('Audit list error:', error); + res.status(500).json({ error: 'Serverfehler' }); + } + }); + // ----- Conversational KI-Chat (Ollama, streaming) ----- // Gated behind OLLAMA_API_KEY. Threads + messages persist in SQLite; the // assistant answer is streamed back via Server-Sent Events. diff --git a/views/admin.ejs b/views/admin.ejs index 1651a38..2ddbfed 100644 --- a/views/admin.ejs +++ b/views/admin.ejs @@ -77,6 +77,13 @@ Jobsuche + <% if (u.id !== currentUserId) { %> +
+ +
+ <% } %> + + + + <% } %> +