#190: configurable session lifetime + server-side idle timeout #241

Merged
fable-5 merged 1 commits from feat/190-session-timeouts into main 2026-07-30 11:26:42 +02:00
11 changed files with 224 additions and 18 deletions
Showing only changes of commit db4c5ce9ca - Show all commits

View File

@ -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);
}

View File

@ -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: '/',
});
}

View File

@ -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<string> {
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<string> {
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);
});
});

View File

@ -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<string> {
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<void> {
await this.prisma.session.deleteMany({ where: { id: hashSessionToken(raw) } });
}
@ -73,8 +113,13 @@ export class SessionsService {
}
listForUser(userId: string): Promise<Session[]> {
// 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' },
});
}

View File

@ -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<CurrentUserShape> {
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);
}

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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: <APP_BASE_URL>`.
- 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

View File

@ -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 · 12 AT · #190
- [ ] **Feed-Token raus aus dem Query-Parameter**, alternativ Feeds hart
abschaltbar · 2 AT · #191

View File

@ -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