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 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 11:03:33 +02:00
co-authored by Claude Opus 4.8
parent d86e40a6ff
commit 2b90e4bbad
3 changed files with 47 additions and 14 deletions
+6
View File
@@ -85,6 +85,12 @@ async function runMigration({ db, dbAll, dbGet, dbRun }) {
if (!(await hasColumn('sessions', 'impersonator_id'))) { if (!(await hasColumn('sessions', 'impersonator_id'))) {
await exec('ALTER TABLE sessions ADD COLUMN impersonator_id INTEGER'); 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) ----------------------------------- // 2. Ensure admin user (idempotent) -----------------------------------
let admin = await dbGet('SELECT id, password_hash FROM users WHERE username = ?', [ADMIN_USERNAME]); let admin = await dbGet('SELECT id, password_hash FROM users WHERE username = ?', [ADMIN_USERNAME]);
+36 -13
View File
@@ -238,22 +238,29 @@ function loginOk(req) {
// Login page + form handler. // Login page + form handler.
app.get('/login', (req, res) => { app.get('/login', (req, res) => {
if (req.user) return res.redirect('/'); 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) => { app.post('/login', async (req, res) => {
const { username, password: plain } = req.body || {}; 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)) { 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); const user = await authenticate(username, plain);
if (!user) { if (!user) {
loginFail(req); 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); loginOk(req);
const token = await createSession(user.id); // Checkbox ist standardmäßig gesetzt; abgewählt sendet der Browser das Feld gar nicht.
setSessionCookie(res, token); const token = await createSession(user.id, gewaehlt);
setSessionCookie(res, token, gewaehlt);
res.redirect('/'); res.redirect('/');
}); });
@@ -658,7 +665,15 @@ function dbRun(sql, params = []) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const SESSION_COOKIE = 'sid'; 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 }. // Minimal cookie parser (no cookie-parser dependency): { name: value }.
function parseCookies(header) { 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. // 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'); 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; return token;
} }
@@ -700,6 +717,7 @@ async function loadSessionUser(token) {
const row = await dbGet( const row = await dbGet(
`SELECT u.id AS id, u.username AS username, u.is_admin AS is_admin, `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.created_at AS created_at, s.last_seen AS last_seen,
s.dauerhaft AS dauerhaft,
s.impersonator_id AS impersonator_id, s.impersonator_id AS impersonator_id,
i.username AS impersonator_username, i.is_admin AS impersonator_is_admin i.username AS impersonator_username, i.is_admin AS impersonator_is_admin
FROM sessions s FROM sessions s
@@ -711,7 +729,8 @@ async function loadSessionUser(token) {
if (!row) return null; if (!row) return null;
const stamp = row.last_seen || row.created_at; // 'YYYY-MM-DD HH:MM:SS' (UTC) const stamp = row.last_seen || row.created_at; // 'YYYY-MM-DD HH:MM:SS' (UTC)
const last = new Date(stamp + 'Z'); 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(() => {}); await dbRun('DELETE FROM sessions WHERE token = ?', [token]).catch(() => {});
return null; 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 // 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 // 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. // 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); const secure = !!(res.req && res.req.secure);
res.cookie(SESSION_COOKIE, token, { const opts = { httpOnly: true, sameSite: 'lax', path: '/', secure };
httpOnly: true, sameSite: 'lax', path: '/', maxAge: SESSION_MAX_AGE * 1000, secure, if (dauerhaft) opts.maxAge = SESSION_MAX_AGE * 1000;
}); res.cookie(SESSION_COOKIE, token, opts);
} }
function clearSessionCookie(res) { function clearSessionCookie(res) {
res.clearCookie(SESSION_COOKIE, { path: '/' }); res.clearCookie(SESSION_COOKIE, { path: '/' });
@@ -1184,6 +1206,7 @@ async function initializeDatabase() {
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP, last_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
impersonator_id INTEGER, impersonator_id INTEGER,
dauerhaft INTEGER NOT NULL DEFAULT 1,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (impersonator_id) REFERENCES users(id) ON DELETE SET NULL FOREIGN KEY (impersonator_id) REFERENCES users(id) ON DELETE SET NULL
) )
+5 -1
View File
@@ -35,13 +35,17 @@
<input id="password" name="password" type="password" required <input id="password" name="password" type="password" required
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-gray-800 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none" /> class="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-gray-800 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none" />
</div> </div>
<label class="flex items-center gap-2 cursor-pointer select-none">
<input id="dauerhaft" name="dauerhaft" type="checkbox" value="1" <%= dauerhaft ? 'checked' : '' %>
class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" />
<span class="text-sm text-gray-700 dark:text-gray-300">Angemeldet bleiben</span>
</label>
<button type="submit" <button type="submit"
class="w-full rounded-lg bg-blue-600 hover:bg-blue-700 transition-colors text-white font-medium py-2.5"> class="w-full rounded-lg bg-blue-600 hover:bg-blue-700 transition-colors text-white font-medium py-2.5">
Anmelden Anmelden
</button> </button>
</form> </form>
</div> </div>
<p class="text-center text-xs text-white/70 mt-6">Multi-User-Plattform</p>
</div> </div>
</body> </body>
</html> </html>