From 2b90e4bbad8c567620e02cbc26dae84590820340 Mon Sep 17 00:00:00 2001 From: Thomas Hackner Date: Tue, 14 Jul 2026 11:03:33 +0200 Subject: [PATCH] Login: "Angemeldet bleiben" waehlbar, Multi-User-Hinweis entfernt Bisher bekam jeder Login pauschal ein 30-Tage-Cookie. Die Checkbox ist per Default gesetzt (unveraendertes Verhalten); wird sie abgewaehlt, gilt ein Session-Cookie ohne Ablaufdatum (weg beim Schliessen des Browsers) und serverseitig eine Frist von 12 Stunden Inaktivitaet - die serverseitige Frist ist die verbindliche, ein Client kann sein Cookie manipulieren. Bestehende Sessions behalten per Spalten-Default die 30 Tage, niemand wird durch das Update ausgeloggt. Co-Authored-By: Claude Opus 4.8 --- lib/migrate-multiuser.js | 6 +++++ server.js | 49 +++++++++++++++++++++++++++++----------- views/login.ejs | 6 ++++- 3 files changed, 47 insertions(+), 14 deletions(-) diff --git a/lib/migrate-multiuser.js b/lib/migrate-multiuser.js index 54a95a1..6e9f3a7 100644 --- a/lib/migrate-multiuser.js +++ b/lib/migrate-multiuser.js @@ -85,6 +85,12 @@ async function runMigration({ db, dbAll, dbGet, dbRun }) { if (!(await hasColumn('sessions', 'impersonator_id'))) { await exec('ALTER TABLE sessions ADD COLUMN impersonator_id INTEGER'); } + // "Angemeldet bleiben": vorher galten alle Sessions 30 Tage. Bestehende + // Sessions behalten dieses Verhalten (Default 1) — niemand wird durch das + // Update ausgeloggt. + if (!(await hasColumn('sessions', 'dauerhaft'))) { + await exec('ALTER TABLE sessions ADD COLUMN dauerhaft INTEGER NOT NULL DEFAULT 1'); + } // 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 f44b8f0..7488060 100644 --- a/server.js +++ b/server.js @@ -238,22 +238,29 @@ function loginOk(req) { // Login page + form handler. app.get('/login', (req, res) => { if (req.user) return res.redirect('/'); - res.render('login', { error: null, username: '' }); + res.render('login', { error: null, username: '', dauerhaft: true }); }); app.post('/login', async (req, res) => { const { username, password: plain } = req.body || {}; + // Bei einem Fehlversuch die Wahl des Nutzers behalten, statt sie zurückzusetzen. + const gewaehlt = (req.body || {}).dauerhaft === '1'; if (!loginGate(req)) { - return res.status(429).render('login', { error: 'Zu viele Versuche. Bitte später erneut versuchen.', username: username || '' }); + return res.status(429).render('login', { + error: 'Zu viele Versuche. Bitte später erneut versuchen.', username: username || '', dauerhaft: gewaehlt, + }); } const user = await authenticate(username, plain); if (!user) { loginFail(req); - return res.status(401).render('login', { error: 'Benutzername oder Passwort falsch.', username: username || '' }); + return res.status(401).render('login', { + error: 'Benutzername oder Passwort falsch.', username: username || '', dauerhaft: gewaehlt, + }); } loginOk(req); - const token = await createSession(user.id); - setSessionCookie(res, token); + // Checkbox ist standardmäßig gesetzt; abgewählt sendet der Browser das Feld gar nicht. + const token = await createSession(user.id, gewaehlt); + setSessionCookie(res, token, gewaehlt); res.redirect('/'); }); @@ -658,7 +665,15 @@ function dbRun(sql, params = []) { // --------------------------------------------------------------------------- const SESSION_COOKIE = 'sid'; -const SESSION_MAX_AGE = 30 * 24 * 3600; // 30 days, in seconds +// Zwei Lebensdauern, je nachdem ob der Nutzer beim Login "Angemeldet bleiben" +// angehakt hat (Standard): +// dauerhaft -> Cookie mit Ablaufdatum, Session lebt 30 Tage ab letzter Aktivität +// nicht -> Session-Cookie (weg beim Schließen des Browsers), serverseitig +// zusätzlich nach 12 Stunden Inaktivität ungültig +// Die serverseitige Frist ist die verbindliche: ein Client kann sein Cookie +// manipulieren, die Zeile in `sessions` nicht. +const SESSION_MAX_AGE = 30 * 24 * 3600; // 30 Tage, in Sekunden +const SESSION_MAX_AGE_KURZ = 12 * 3600; // 12 Stunden, in Sekunden // Minimal cookie parser (no cookie-parser dependency): { name: value }. function parseCookies(header) { @@ -675,9 +690,11 @@ function parseCookies(header) { } // Create a session row for a user and return the opaque token to store in the cookie. -async function createSession(userId) { +// `dauerhaft` merkt sich die Wahl aus der Login-Maske und entscheidet später über +// die Ablauffrist (siehe loadSessionUser). +async function createSession(userId, dauerhaft = true) { const token = crypto.randomBytes(32).toString('hex'); - await dbRun('INSERT INTO sessions (token, user_id) VALUES (?, ?)', [token, userId]); + await dbRun('INSERT INTO sessions (token, user_id, dauerhaft) VALUES (?, ?, ?)', [token, userId, dauerhaft ? 1 : 0]); return token; } @@ -700,6 +717,7 @@ async function loadSessionUser(token) { 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, + s.dauerhaft AS dauerhaft, s.impersonator_id AS impersonator_id, i.username AS impersonator_username, i.is_admin AS impersonator_is_admin FROM sessions s @@ -711,7 +729,8 @@ async function loadSessionUser(token) { if (!row) return null; const stamp = row.last_seen || row.created_at; // 'YYYY-MM-DD HH:MM:SS' (UTC) const last = new Date(stamp + 'Z'); - if (isNaN(last.getTime()) || (Date.now() - last.getTime()) / 1000 > SESSION_MAX_AGE) { + const frist = row.dauerhaft ? SESSION_MAX_AGE : SESSION_MAX_AGE_KURZ; + if (isNaN(last.getTime()) || (Date.now() - last.getTime()) / 1000 > frist) { await dbRun('DELETE FROM sessions WHERE token = ?', [token]).catch(() => {}); return null; } @@ -740,11 +759,14 @@ async function authenticate(username, plain) { // Set/clear the session cookie on a response. The Secure flag is set whenever // the request arrived over TLS (Caddy terminates it; trust proxy lets us see // that via req.secure), so the cookie is never leaked over plain HTTP. -function setSessionCookie(res, token) { +// Ohne "Angemeldet bleiben" bekommt das Cookie kein maxAge: der Browser wirft es +// beim Schließen weg. Das ist der Sinn der Abwahl — auf einem fremden Rechner soll +// nichts zurückbleiben. +function setSessionCookie(res, token, dauerhaft = true) { const secure = !!(res.req && res.req.secure); - res.cookie(SESSION_COOKIE, token, { - httpOnly: true, sameSite: 'lax', path: '/', maxAge: SESSION_MAX_AGE * 1000, secure, - }); + const opts = { httpOnly: true, sameSite: 'lax', path: '/', secure }; + if (dauerhaft) opts.maxAge = SESSION_MAX_AGE * 1000; + res.cookie(SESSION_COOKIE, token, opts); } function clearSessionCookie(res) { res.clearCookie(SESSION_COOKIE, { path: '/' }); @@ -1184,6 +1206,7 @@ async function initializeDatabase() { created_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_seen DATETIME DEFAULT CURRENT_TIMESTAMP, impersonator_id INTEGER, + dauerhaft INTEGER NOT NULL DEFAULT 1, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (impersonator_id) REFERENCES users(id) ON DELETE SET NULL ) diff --git a/views/login.ejs b/views/login.ejs index b06363b..7f386f5 100644 --- a/views/login.ejs +++ b/views/login.ejs @@ -35,13 +35,17 @@ + -

Multi-User-Plattform

\ No newline at end of file