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:
@@ -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]);
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
+5
-1
@@ -35,13 +35,17 @@
|
||||
<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" />
|
||||
</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"
|
||||
class="w-full rounded-lg bg-blue-600 hover:bg-blue-700 transition-colors text-white font-medium py-2.5">
|
||||
Anmelden
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<p class="text-center text-xs text-white/70 mt-6">Multi-User-Plattform</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user