From db4c5ce9cada7fcf6375dcf24044b498b388ca1f Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Thu, 30 Jul 2026 11:11:15 +0200 Subject: [PATCH] #190: configurable session lifetime with a server-side idle timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SESSION_ABSOLUTE_HOURS (default 168 h) caps a session's total lifetime from login: expiresAt is set once at creation and never extended — the old sliding 30-day renewal is gone. SESSION_IDLE_HOURS (default 72 h) ends sessions unused for that long, enforced server-side against lastSeenAt with a write throttle scaled to the idle bound so short idle windows still renew. Expired rows are removed on validation and the session list applies both bounds, so idle-dead sessions never show as active. The cookie maxAge follows the configured absolute bound. Documented in .env.example (with the VS-NfD reference values for the upcoming hardening guide #227), compose passes the variables through, security.md and ADR 0007 record the amendment. Refs #190 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ --- apps/api/src/auth/auth.controller.ts | 9 +- apps/api/src/auth/auth.guard.ts | 16 ++- apps/api/src/auth/sessions.service.db.test.ts | 102 ++++++++++++++++++ apps/api/src/auth/sessions.service.ts | 65 +++++++++-- apps/api/src/setup/setup.controller.ts | 8 +- deploy/compose/.env.example | 8 ++ deploy/compose/docker-compose.yml | 4 + .../adr/0007-auth-sessions-oidc-ready.md | 7 +- docs/architecture/security.md | 5 + docs/vs-nfd/20-massnahmenplan.md | 2 +- packages/shared/src/env.ts | 16 +++ 11 files changed, 224 insertions(+), 18 deletions(-) create mode 100644 apps/api/src/auth/sessions.service.db.test.ts diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index 2ffba52..2d2ab64 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -26,7 +26,7 @@ import { toCurrentUser, } from './auth.guard'; import { AuthService } from './auth.service'; -import { SessionsService } from './sessions.service'; +import { SessionsService, sessionAbsoluteMs } from './sessions.service'; @AuthenticatedOnly() // routes reachable without a session opt out via @Public @Controller('auth') @@ -90,7 +90,12 @@ export class AuthController { input.password, request.headers['user-agent'], ); - setSessionCookie(response, sessionToken, this.config.env.NODE_ENV === 'production'); + setSessionCookie( + response, + sessionToken, + this.config.env.NODE_ENV === 'production', + sessionAbsoluteMs(this.config.env), + ); return toCurrentUser(user); } diff --git a/apps/api/src/auth/auth.guard.ts b/apps/api/src/auth/auth.guard.ts index 7c84c20..787779e 100644 --- a/apps/api/src/auth/auth.guard.ts +++ b/apps/api/src/auth/auth.guard.ts @@ -49,13 +49,23 @@ export function toCurrentUser(user: User): CurrentUserShape { }; } -/** Session cookie contract shared by login and the setup wizard (issue #80). */ -export function setSessionCookie(response: Response, token: string, production: boolean): void { +/** + * Session cookie contract shared by login and the setup wizard (issue #80). + * `maxAgeMs` follows the configured absolute session bound (#190) — the + * server-side idle/absolute checks are authoritative, the cookie merely + * stops outliving them. + */ +export function setSessionCookie( + response: Response, + token: string, + production: boolean, + maxAgeMs: number, +): void { response.cookie(SESSION_COOKIE, token, { httpOnly: true, sameSite: 'lax', secure: production, - maxAge: 30 * 24 * 60 * 60 * 1000, + maxAge: maxAgeMs, path: '/', }); } diff --git a/apps/api/src/auth/sessions.service.db.test.ts b/apps/api/src/auth/sessions.service.db.test.ts new file mode 100644 index 0000000..4ecd982 --- /dev/null +++ b/apps/api/src/auth/sessions.service.db.test.ts @@ -0,0 +1,102 @@ +import { afterAll, describe, expect, it } from 'vitest'; + +import { AppConfig } from '../config/app-config.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; +import { UsersService } from '../users/users.service'; +import { SessionsService, hashSessionToken } from './sessions.service'; + +const HOUR = 60 * 60 * 1000; + +/** A config stub with just the session bounds (absolute 2 h, idle 1 h). */ +function configWith(absoluteHours: number, idleHours: number): AppConfig { + return { + env: { SESSION_ABSOLUTE_HOURS: absoluteHours, SESSION_IDLE_HOURS: idleHours }, + } as AppConfig; +} + +describe.skipIf(!hasTestDb)('SessionsService bounds (database, issue #190)', () => { + const prisma = hasTestDb ? (createTestPrisma() as unknown as PrismaService) : null!; + const sessions = hasTestDb ? new SessionsService(prisma, configWith(2, 1)) : null!; + const suffix = uniqueSuffix(); + let userId: string; + + async function makeUser(): Promise { + if (userId) return userId; + const users = new UsersService(prisma); + const user = await users.createUser({ + username: `sess-${suffix}`, + email: `sess-${suffix}@example.org`, + displayName: 'Sess Test', + password: 'session bounds pass 1', + locale: 'en', + }); + userId = user.id; + return userId; + } + + /** Creates a session and rewrites its timestamps to simulate age. */ + async function sessionAgedTo(expiresInMs: number, lastSeenAgoMs: number): Promise { + const raw = await sessions.create(await makeUser(), 'test-agent'); + await prisma.session.update({ + where: { id: hashSessionToken(raw) }, + data: { + expiresAt: new Date(Date.now() + expiresInMs), + lastSeenAt: new Date(Date.now() - lastSeenAgoMs), + }, + }); + return raw; + } + + afterAll(async () => { + if (!hasTestDb) return; + await prisma.session.deleteMany({ where: { userId } }); + await prisma.userIdentity.deleteMany({ where: { userId } }); + await prisma.user.deleteMany({ where: { id: userId } }); + await prisma.$disconnect(); + }); + + it('sets the absolute bound at creation', async () => { + const raw = await sessions.create(await makeUser(), 'test-agent'); + const row = await prisma.session.findUniqueOrThrow({ where: { id: hashSessionToken(raw) } }); + const msLeft = row.expiresAt.getTime() - Date.now(); + expect(msLeft).toBeGreaterThan(1.9 * HOUR); + expect(msLeft).toBeLessThanOrEqual(2 * HOUR); + await sessions.destroyByRawToken(raw); + }); + + it('rejects a session past its absolute bound and removes the row', async () => { + const raw = await sessionAgedTo(-1000, 0); + expect(await sessions.validate(raw)).toBeNull(); + expect(await prisma.session.findUnique({ where: { id: hashSessionToken(raw) } })).toBeNull(); + }); + + it('rejects a session idle past the idle bound even before its absolute bound', async () => { + const raw = await sessionAgedTo(HOUR, 1.5 * HOUR); + expect(await sessions.validate(raw)).toBeNull(); + expect(await prisma.session.findUnique({ where: { id: hashSessionToken(raw) } })).toBeNull(); + }); + + it('renews the idle bound on active use but never extends the absolute bound', async () => { + const raw = await sessionAgedTo(HOUR, 0.5 * HOUR); + const before = await prisma.session.findUniqueOrThrow({ + where: { id: hashSessionToken(raw) }, + }); + expect(await sessions.validate(raw)).not.toBeNull(); + const after = await prisma.session.findUniqueOrThrow({ where: { id: hashSessionToken(raw) } }); + expect(after.lastSeenAt.getTime()).toBeGreaterThan(before.lastSeenAt.getTime()); + expect(after.expiresAt.getTime()).toBe(before.expiresAt.getTime()); + await sessions.destroyByRawToken(raw); + }); + + it('hides idle-expired sessions from the session list', async () => { + const live = await sessionAgedTo(HOUR, 0); + const idle = await sessionAgedTo(HOUR, 1.5 * HOUR); + const listed = await sessions.listForUser(userId); + const ids = listed.map((s) => s.id); + expect(ids).toContain(hashSessionToken(live)); + expect(ids).not.toContain(hashSessionToken(idle)); + await sessions.destroyByRawToken(live); + await sessions.destroyByRawToken(idle); + }); +}); diff --git a/apps/api/src/auth/sessions.service.ts b/apps/api/src/auth/sessions.service.ts index 782927f..7862c3c 100644 --- a/apps/api/src/auth/sessions.service.ts +++ b/apps/api/src/auth/sessions.service.ts @@ -3,24 +3,52 @@ import { createHash, randomBytes } from 'node:crypto'; import { Injectable } from '@nestjs/common'; import { Session, User } from '@prisma/client'; -import { PrismaService } from '../prisma/prisma.service'; +import type { ApiEnv } from '@dorfteich/shared'; -const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // sliding 30 days -const REFRESH_AT_MOST_EVERY_MS = 60 * 60 * 1000; // avoid write storms +import { AppConfig } from '../config/app-config.service'; +import { PrismaService } from '../prisma/prisma.service'; export interface ValidatedSession { session: Session; user: User; } +/** The absolute session bound — also the cookie `maxAge` (auth.guard.ts). */ +export function sessionAbsoluteMs(env: ApiEnv): number { + return env.SESSION_ABSOLUTE_HOURS * 60 * 60 * 1000; +} + /** * Opaque server-side sessions (ADR 0007). The cookie value is 32 random * bytes; the database stores only its SHA-256 hash as the row id, so a * database leak cannot be replayed as cookies. + * + * Two configurable bounds (issue #190): `expiresAt` is the ABSOLUTE limit, + * set once at creation and never extended; the IDLE limit is enforced + * server-side against `lastSeenAt`, which active use renews. The old + * sliding 30-day expiry is gone — activity keeps a session alive only up + * to the absolute bound. */ @Injectable() export class SessionsService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly config: AppConfig, + ) {} + + private absoluteMs(): number { + return sessionAbsoluteMs(this.config.env); + } + + private idleMs(): number { + // An idle bound above the absolute one would never fire anyway. + return Math.min(this.config.env.SESSION_IDLE_HOURS * 60 * 60 * 1000, this.absoluteMs()); + } + + /** `lastSeenAt` write throttle: fine-grained enough for the idle bound. */ + private refreshAtMostEveryMs(): number { + return Math.min(60 * 60 * 1000, Math.floor(this.idleMs() / 10)); + } async create(userId: string, userAgent: string | undefined): Promise { const raw = randomBytes(32).toString('base64url'); @@ -28,7 +56,7 @@ export class SessionsService { data: { id: hashSessionToken(raw), userId, - expiresAt: new Date(Date.now() + SESSION_TTL_MS), + expiresAt: new Date(Date.now() + this.absoluteMs()), userAgent: summarizeUserAgent(userAgent), }, }); @@ -40,20 +68,32 @@ export class SessionsService { where: { id: hashSessionToken(raw) }, include: { user: true }, }); - if (!session || session.expiresAt <= new Date()) return null; + if (!session) return null; + const now = Date.now(); + const idleExpired = now - session.lastSeenAt.getTime() >= this.idleMs(); + if (session.expiresAt.getTime() <= now || idleExpired) { + // Expired either way — remove the row so the session list stays truthful. + await this.prisma.session.deleteMany({ where: { id: session.id } }); + return null; + } if (session.user.status === 'DISABLED') return null; - // Sliding expiration, refreshed at most once per hour. - if (Date.now() - session.lastSeenAt.getTime() > REFRESH_AT_MOST_EVERY_MS) { + // Renew the idle bound (throttled against write storms); the absolute + // `expiresAt` is deliberately never touched. + if (now - session.lastSeenAt.getTime() > this.refreshAtMostEveryMs()) { await this.prisma.session.update({ where: { id: session.id }, - data: { lastSeenAt: new Date(), expiresAt: new Date(Date.now() + SESSION_TTL_MS) }, + data: { lastSeenAt: new Date(now) }, }); } const { user, ...bare } = session; return { session: bare as Session, user }; } + private idleCutoff(now: number): Date { + return new Date(now - this.idleMs()); + } + async destroyByRawToken(raw: string): Promise { await this.prisma.session.deleteMany({ where: { id: hashSessionToken(raw) } }); } @@ -73,8 +113,13 @@ export class SessionsService { } listForUser(userId: string): Promise { + // Both bounds, so an idle-expired session never shows as active. return this.prisma.session.findMany({ - where: { userId, expiresAt: { gt: new Date() } }, + where: { + userId, + expiresAt: { gt: new Date() }, + lastSeenAt: { gt: this.idleCutoff(Date.now()) }, + }, orderBy: { lastSeenAt: 'desc' }, }); } diff --git a/apps/api/src/setup/setup.controller.ts b/apps/api/src/setup/setup.controller.ts index ba5e756..5ede8b5 100644 --- a/apps/api/src/setup/setup.controller.ts +++ b/apps/api/src/setup/setup.controller.ts @@ -15,6 +15,7 @@ import type { Response } from 'express'; import { SiteAdminGuard } from '../admin/site-admin.guard'; import { AuthedRequest, Public, setSessionCookie, toCurrentUser } from '../auth/auth.guard'; +import { sessionAbsoluteMs } from '../auth/sessions.service'; import { ZodValidationPipe } from '../common/zod-validation.pipe'; import { AppConfig } from '../config/app-config.service'; import { RateLimit } from '../rate-limit/rate-limit.guard'; @@ -53,7 +54,12 @@ export class SetupController { ): Promise { const admin = await this.setup.createAdmin(input); const sessionToken = await this.setup.startSession(admin, request.headers['user-agent']); - setSessionCookie(response, sessionToken, this.config.env.NODE_ENV === 'production'); + setSessionCookie( + response, + sessionToken, + this.config.env.NODE_ENV === 'production', + sessionAbsoluteMs(this.config.env), + ); return toCurrentUser(admin); } diff --git a/deploy/compose/.env.example b/deploy/compose/.env.example index d72e3d6..1301b00 100644 --- a/deploy/compose/.env.example +++ b/deploy/compose/.env.example @@ -32,6 +32,14 @@ COLLAB_PORT=8102 # pino log level: fatal|error|warn|info|debug|trace LOG_LEVEL=info +# Session bounds in hours (issue #190). ABSOLUTE caps the total session +# lifetime from login (also the cookie maxAge) — activity never extends it. +# IDLE ends sessions unused for that long; activity renews it, enforced +# server-side. Unset = defaults: 168 (7 days) absolute, 72 (3 days) idle. +# VS-NfD reference operation (hardening guide): 12 absolute, 1 idle. +#SESSION_ABSOLUTE_HOURS=168 +#SESSION_IDLE_HOURS=72 + # Compose project name; set per stage (dorfteich-test, dorfteich-int, …). COMPOSE_PROJECT_NAME=dorfteich diff --git a/deploy/compose/docker-compose.yml b/deploy/compose/docker-compose.yml index 9467eff..35a82fc 100644 --- a/deploy/compose/docker-compose.yml +++ b/deploy/compose/docker-compose.yml @@ -51,6 +51,10 @@ services: # Public URL of this stage — e-mail links and the CSRF origin check # depend on it matching what browsers actually use. APP_BASE_URL: ${APP_BASE_URL:-http://localhost:5173} + # Session bounds in hours (issue #190); empty = application defaults + # (absolute 168 h, idle 72 h). Hardened deployments set them lower. + SESSION_ABSOLUTE_HOURS: ${SESSION_ABSOLUTE_HOURS:-} + SESSION_IDLE_HOURS: ${SESSION_IDLE_HOURS:-} # SMTP relay. Empty (= unset in .env) is fine: the setup wizard writes # the relay to the secret store on the `secrets` volume (issue #80); # values set here in the stage .env always win over the store. diff --git a/docs/architecture/adr/0007-auth-sessions-oidc-ready.md b/docs/architecture/adr/0007-auth-sessions-oidc-ready.md index bdb42b0..4051452 100644 --- a/docs/architecture/adr/0007-auth-sessions-oidc-ready.md +++ b/docs/architecture/adr/0007-auth-sessions-oidc-ready.md @@ -17,7 +17,12 @@ later without schema surgery. - **Server-side sessions** stored in PostgreSQL, referenced by an opaque `HttpOnly; Secure; SameSite=Lax` cookie. No JWTs for browser sessions - (revocability and simplicity win). Sliding expiration, default 30 days. + (revocability and simplicity win). ~~Sliding expiration, default 30 + days.~~ _Amended by issue #190 (2026-07-30): a configurable **absolute** + bound (`SESSION_ABSOLUTE_HOURS`, default 7 days, never extended by + activity) plus a configurable **idle** bound (`SESSION_IDLE_HOURS`, + default 3 days, renewed by activity, enforced server-side against + `lastSeenAt`). The sliding 30-day expiry is gone._ - Passwords hashed with **Argon2id** (tuned parameters documented in code). - **E-mail flows** (verification, password reset) use single-use, expiring, hashed tokens; mail is sent via SMTP (instance-configured, see setup diff --git a/docs/architecture/security.md b/docs/architecture/security.md index d616dba..4c5aa7f 100644 --- a/docs/architecture/security.md +++ b/docs/architecture/security.md @@ -22,6 +22,11 @@ or sloppy plugin authors, compromised dependencies. the check — the exception is structural, not a header loophole; a request that does carry the session cookie is always checked. Scripted cookie clients must send `Origin: `. +- Session bounds are configurable (issue #190): an absolute lifetime + (`SESSION_ABSOLUTE_HOURS`, default 7 days, never extended by activity — + also the cookie `maxAge`) and an idle timeout (`SESSION_IDLE_HOURS`, + default 3 days), both enforced server-side, the idle bound against + `lastSeenAt`. - E-mail verification (double opt-in) before an account can create content; password reset via single-use hashed tokens; both rate-limited. - Rate limiting (DB-backed) on login, signup, reset, and API; lockout diff --git a/docs/vs-nfd/20-massnahmenplan.md b/docs/vs-nfd/20-massnahmenplan.md index 62e360d..e51e9b7 100644 --- a/docs/vs-nfd/20-massnahmenplan.md +++ b/docs/vs-nfd/20-massnahmenplan.md @@ -93,7 +93,7 @@ chain`_ Dual-Verify-Fenster einplanen. - [x] **CSRF fail-closed** — fehlendes Origin _und_ Referer wird derzeit durchgelassen · 1 AT · #189 -- [ ] **Session-Timeout konfigurierbar**, Default deutlich unter 30 Tagen, +- [x] **Session-Timeout konfigurierbar**, Default deutlich unter 30 Tagen, separates Idle-Timeout · 1–2 AT · #190 - [ ] **Feed-Token raus aus dem Query-Parameter**, alternativ Feeds hart abschaltbar · 2 AT · #191 diff --git a/packages/shared/src/env.ts b/packages/shared/src/env.ts index 0c236e7..3588628 100644 --- a/packages/shared/src/env.ts +++ b/packages/shared/src/env.ts @@ -60,6 +60,22 @@ export const apiEnvSchema = z.object({ .transform((value) => value === 'true'), /** Public base URL of this instance — used in e-mail links. */ APP_BASE_URL: z.string().url().default('http://localhost:5173'), + /** + * Session bounds (issue #190). ABSOLUTE caps a session's total lifetime + * from login — active use never extends it. IDLE ends a session that has + * not been used for that long; activity renews it (server-side against + * `lastSeenAt`, not just the cookie). Defaults are deliberately well + * below the old sliding 30 days; hardened deployments set them lower + * (see deploy/compose/.env.example). + */ + SESSION_ABSOLUTE_HOURS: z.coerce + .number() + .positive() + .default(7 * 24), + SESSION_IDLE_HOURS: z.coerce + .number() + .positive() + .default(3 * 24), ...smtpFields, /** * Filesystem root for uploaded files (ADR 0011). The compose stack -- 2.45.2