#190: configurable session lifetime with a server-side idle timeout
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 4m53s
CI / Build container images (pull_request) Successful in 3m55s
CI / Auth e2e pack (pull_request) Successful in 7m53s
CI / Import/export fidelity gate (pull_request) Successful in 55s
CD / Build and push images (push) Successful in 19s
CD / Deploy to Test (push) Successful in 12s
CD / Smoke tests against Test (push) Successful in 1m17s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 4m53s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m37s
CI / Import/export fidelity gate (push) Successful in 52s
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 4m53s
CI / Build container images (pull_request) Successful in 3m55s
CI / Auth e2e pack (pull_request) Successful in 7m53s
CI / Import/export fidelity gate (pull_request) Successful in 55s
CD / Build and push images (push) Successful in 19s
CD / Deploy to Test (push) Successful in 12s
CD / Smoke tests against Test (push) Successful in 1m17s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 4m53s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m37s
CI / Import/export fidelity gate (push) Successful in 52s
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
This commit is contained in:
parent
214e707102
commit
db4c5ce9ca
@ -26,7 +26,7 @@ import {
|
|||||||
toCurrentUser,
|
toCurrentUser,
|
||||||
} from './auth.guard';
|
} from './auth.guard';
|
||||||
import { AuthService } from './auth.service';
|
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
|
@AuthenticatedOnly() // routes reachable without a session opt out via @Public
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
@ -90,7 +90,12 @@ export class AuthController {
|
|||||||
input.password,
|
input.password,
|
||||||
request.headers['user-agent'],
|
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);
|
return toCurrentUser(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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, {
|
response.cookie(SESSION_COOKIE, token, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
secure: production,
|
secure: production,
|
||||||
maxAge: 30 * 24 * 60 * 60 * 1000,
|
maxAge: maxAgeMs,
|
||||||
path: '/',
|
path: '/',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
102
apps/api/src/auth/sessions.service.db.test.ts
Normal file
102
apps/api/src/auth/sessions.service.db.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -3,24 +3,52 @@ import { createHash, randomBytes } from 'node:crypto';
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Session, User } from '@prisma/client';
|
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
|
import { AppConfig } from '../config/app-config.service';
|
||||||
const REFRESH_AT_MOST_EVERY_MS = 60 * 60 * 1000; // avoid write storms
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
export interface ValidatedSession {
|
export interface ValidatedSession {
|
||||||
session: Session;
|
session: Session;
|
||||||
user: User;
|
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
|
* 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
|
* bytes; the database stores only its SHA-256 hash as the row id, so a
|
||||||
* database leak cannot be replayed as cookies.
|
* 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()
|
@Injectable()
|
||||||
export class SessionsService {
|
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> {
|
async create(userId: string, userAgent: string | undefined): Promise<string> {
|
||||||
const raw = randomBytes(32).toString('base64url');
|
const raw = randomBytes(32).toString('base64url');
|
||||||
@ -28,7 +56,7 @@ export class SessionsService {
|
|||||||
data: {
|
data: {
|
||||||
id: hashSessionToken(raw),
|
id: hashSessionToken(raw),
|
||||||
userId,
|
userId,
|
||||||
expiresAt: new Date(Date.now() + SESSION_TTL_MS),
|
expiresAt: new Date(Date.now() + this.absoluteMs()),
|
||||||
userAgent: summarizeUserAgent(userAgent),
|
userAgent: summarizeUserAgent(userAgent),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -40,20 +68,32 @@ export class SessionsService {
|
|||||||
where: { id: hashSessionToken(raw) },
|
where: { id: hashSessionToken(raw) },
|
||||||
include: { user: true },
|
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;
|
if (session.user.status === 'DISABLED') return null;
|
||||||
|
|
||||||
// Sliding expiration, refreshed at most once per hour.
|
// Renew the idle bound (throttled against write storms); the absolute
|
||||||
if (Date.now() - session.lastSeenAt.getTime() > REFRESH_AT_MOST_EVERY_MS) {
|
// `expiresAt` is deliberately never touched.
|
||||||
|
if (now - session.lastSeenAt.getTime() > this.refreshAtMostEveryMs()) {
|
||||||
await this.prisma.session.update({
|
await this.prisma.session.update({
|
||||||
where: { id: session.id },
|
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;
|
const { user, ...bare } = session;
|
||||||
return { session: bare as Session, user };
|
return { session: bare as Session, user };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private idleCutoff(now: number): Date {
|
||||||
|
return new Date(now - this.idleMs());
|
||||||
|
}
|
||||||
|
|
||||||
async destroyByRawToken(raw: string): Promise<void> {
|
async destroyByRawToken(raw: string): Promise<void> {
|
||||||
await this.prisma.session.deleteMany({ where: { id: hashSessionToken(raw) } });
|
await this.prisma.session.deleteMany({ where: { id: hashSessionToken(raw) } });
|
||||||
}
|
}
|
||||||
@ -73,8 +113,13 @@ export class SessionsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
listForUser(userId: string): Promise<Session[]> {
|
listForUser(userId: string): Promise<Session[]> {
|
||||||
|
// Both bounds, so an idle-expired session never shows as active.
|
||||||
return this.prisma.session.findMany({
|
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' },
|
orderBy: { lastSeenAt: 'desc' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import type { Response } from 'express';
|
|||||||
|
|
||||||
import { SiteAdminGuard } from '../admin/site-admin.guard';
|
import { SiteAdminGuard } from '../admin/site-admin.guard';
|
||||||
import { AuthedRequest, Public, setSessionCookie, toCurrentUser } from '../auth/auth.guard';
|
import { AuthedRequest, Public, setSessionCookie, toCurrentUser } from '../auth/auth.guard';
|
||||||
|
import { sessionAbsoluteMs } from '../auth/sessions.service';
|
||||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
import { AppConfig } from '../config/app-config.service';
|
import { AppConfig } from '../config/app-config.service';
|
||||||
import { RateLimit } from '../rate-limit/rate-limit.guard';
|
import { RateLimit } from '../rate-limit/rate-limit.guard';
|
||||||
@ -53,7 +54,12 @@ export class SetupController {
|
|||||||
): Promise<CurrentUserShape> {
|
): Promise<CurrentUserShape> {
|
||||||
const admin = await this.setup.createAdmin(input);
|
const admin = await this.setup.createAdmin(input);
|
||||||
const sessionToken = await this.setup.startSession(admin, request.headers['user-agent']);
|
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);
|
return toCurrentUser(admin);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -32,6 +32,14 @@ COLLAB_PORT=8102
|
|||||||
# pino log level: fatal|error|warn|info|debug|trace
|
# pino log level: fatal|error|warn|info|debug|trace
|
||||||
LOG_LEVEL=info
|
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; set per stage (dorfteich-test, dorfteich-int, …).
|
||||||
COMPOSE_PROJECT_NAME=dorfteich
|
COMPOSE_PROJECT_NAME=dorfteich
|
||||||
|
|
||||||
|
|||||||
@ -51,6 +51,10 @@ services:
|
|||||||
# Public URL of this stage — e-mail links and the CSRF origin check
|
# Public URL of this stage — e-mail links and the CSRF origin check
|
||||||
# depend on it matching what browsers actually use.
|
# depend on it matching what browsers actually use.
|
||||||
APP_BASE_URL: ${APP_BASE_URL:-http://localhost:5173}
|
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
|
# SMTP relay. Empty (= unset in .env) is fine: the setup wizard writes
|
||||||
# the relay to the secret store on the `secrets` volume (issue #80);
|
# the relay to the secret store on the `secrets` volume (issue #80);
|
||||||
# values set here in the stage .env always win over the store.
|
# values set here in the stage .env always win over the store.
|
||||||
|
|||||||
@ -17,7 +17,12 @@ later without schema surgery.
|
|||||||
|
|
||||||
- **Server-side sessions** stored in PostgreSQL, referenced by an opaque
|
- **Server-side sessions** stored in PostgreSQL, referenced by an opaque
|
||||||
`HttpOnly; Secure; SameSite=Lax` cookie. No JWTs for browser sessions
|
`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).
|
- Passwords hashed with **Argon2id** (tuned parameters documented in code).
|
||||||
- **E-mail flows** (verification, password reset) use single-use, expiring,
|
- **E-mail flows** (verification, password reset) use single-use, expiring,
|
||||||
hashed tokens; mail is sent via SMTP (instance-configured, see setup
|
hashed tokens; mail is sent via SMTP (instance-configured, see setup
|
||||||
|
|||||||
@ -22,6 +22,11 @@ or sloppy plugin authors, compromised dependencies.
|
|||||||
the check — the exception is structural, not a header loophole; a
|
the check — the exception is structural, not a header loophole; a
|
||||||
request that does carry the session cookie is always checked. Scripted
|
request that does carry the session cookie is always checked. Scripted
|
||||||
cookie clients must send `Origin: <APP_BASE_URL>`.
|
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;
|
- E-mail verification (double opt-in) before an account can create content;
|
||||||
password reset via single-use hashed tokens; both rate-limited.
|
password reset via single-use hashed tokens; both rate-limited.
|
||||||
- Rate limiting (DB-backed) on login, signup, reset, and API; lockout
|
- Rate limiting (DB-backed) on login, signup, reset, and API; lockout
|
||||||
|
|||||||
@ -93,7 +93,7 @@ chain`_
|
|||||||
Dual-Verify-Fenster einplanen.
|
Dual-Verify-Fenster einplanen.
|
||||||
- [x] **CSRF fail-closed** — fehlendes Origin _und_ Referer wird derzeit
|
- [x] **CSRF fail-closed** — fehlendes Origin _und_ Referer wird derzeit
|
||||||
durchgelassen · 1 AT · #189
|
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
|
separates Idle-Timeout · 1–2 AT · #190
|
||||||
- [ ] **Feed-Token raus aus dem Query-Parameter**, alternativ Feeds hart
|
- [ ] **Feed-Token raus aus dem Query-Parameter**, alternativ Feeds hart
|
||||||
abschaltbar · 2 AT · #191
|
abschaltbar · 2 AT · #191
|
||||||
|
|||||||
@ -60,6 +60,22 @@ export const apiEnvSchema = z.object({
|
|||||||
.transform((value) => value === 'true'),
|
.transform((value) => value === 'true'),
|
||||||
/** Public base URL of this instance — used in e-mail links. */
|
/** Public base URL of this instance — used in e-mail links. */
|
||||||
APP_BASE_URL: z.string().url().default('http://localhost:5173'),
|
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,
|
...smtpFields,
|
||||||
/**
|
/**
|
||||||
* Filesystem root for uploaded files (ADR 0011). The compose stack
|
* Filesystem root for uploaded files (ADR 0011). The compose stack
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user