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'))) {
|
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]);
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
Reference in New Issue
Block a user