feat: add session idle timeout and concurrent session limit

Idle timeout: reads `sessionIdleTimeoutMinutes` from settings, tracks
last activity in Redis (with Postgres fallback on cache miss), and
invalidates sessions that exceed the configured idle window.

Concurrent session limit: reads `maxSessionsPerUser` from settings
and evicts oldest sessions (FIFO) when a new login exceeds the cap.

Both features are opt-in (disabled when value is 0 or absent).
This commit is contained in:
SnapOtter
2026-06-13 22:07:31 +08:00
parent 7ed043ec53
commit 3cc4ef6895
2 changed files with 73 additions and 1 deletions
+23
View File
@@ -0,0 +1,23 @@
import { eq } from "drizzle-orm";
import { db, schema } from "../db/index.js";
/**
* Read a numeric setting from the DB `settings` table.
* Returns `defaultValue` when the key is missing, non-numeric, or on DB error.
*/
export async function getSettingNumber(key: string, defaultValue = 0): Promise<number> {
try {
const result = await db
.select({ value: schema.settings.value })
.from(schema.settings)
.where(eq(schema.settings.key, key))
.limit(1);
if (result.length > 0) {
const num = Number(result[0].value);
if (!Number.isNaN(num)) return num;
}
} catch {
/* DB not ready or key absent -- fall through */
}
return defaultValue;
}
+50 -1
View File
@@ -1,11 +1,13 @@
import { createHash, randomBytes, randomUUID, scrypt, timingSafeEqual } from "node:crypto";
import { promisify } from "node:util";
import { and, eq, ne, sql } from "drizzle-orm";
import { and, asc, eq, ne, sql } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { env } from "../config.js";
import { db, schema } from "../db/index.js";
import { sharedRedis } from "../jobs/connection.js";
import { auditFromRequest, sanitizeAuditInput } from "../lib/audit.js";
import { getSettingNumber } from "../lib/settings-helpers.js";
import { getPermissions, requirePermission } from "../permissions.js";
const scryptAsync = promisify(scrypt);
@@ -319,6 +321,23 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
expiresAt,
});
// ── Concurrent session limit (FIFO eviction) ──────────────
const maxSessions = await getSettingNumber("maxSessionsPerUser");
if (maxSessions > 0) {
const sessions = await db
.select({ id: schema.sessions.id, createdAt: schema.sessions.createdAt })
.from(schema.sessions)
.where(eq(schema.sessions.userId, user.id))
.orderBy(asc(schema.sessions.createdAt));
if (sessions.length > maxSessions) {
const toDelete = sessions.slice(0, sessions.length - maxSessions);
for (const s of toDelete) {
await db.delete(schema.sessions).where(eq(schema.sessions.id, s.id));
}
}
}
await audit("LOGIN_SUCCESS", { userId: user.id, username: user.username });
const [teamRow] = await db.select().from(schema.teams).where(eq(schema.teams.id, user.team));
@@ -1028,6 +1047,36 @@ export async function authMiddleware(app: FastifyInstance): Promise<void> {
return reply.status(401).send({ error: "User not found" });
}
// ── Idle timeout enforcement ───────────────────────────────────
const idleTimeoutMinutes = await getSettingNumber("sessionIdleTimeoutMinutes");
if (idleTimeoutMinutes > 0) {
const redis = sharedRedis();
const idleKey = `session:idle:${token}`;
const lastSeen = await redis.get(idleKey);
if (!lastSeen) {
// Redis key expired or first request -- check Postgres lastActivity
if (session.lastActivity) {
const elapsed = Date.now() - session.lastActivity.getTime();
if (elapsed > idleTimeoutMinutes * 60 * 1000) {
await db.delete(schema.sessions).where(eq(schema.sessions.id, token));
if (isPublic) return;
return reply
.status(401)
.send({ error: "Session expired due to inactivity", code: "IDLE_TIMEOUT" });
}
}
// Flush lastActivity to Postgres on cache miss (avoids per-request DB writes)
await db
.update(schema.sessions)
.set({ lastActivity: new Date() })
.where(eq(schema.sessions.id, token));
}
// Refresh Redis key with TTL = idle timeout
await redis.setex(idleKey, idleTimeoutMinutes * 60, Date.now().toString());
}
// Attach user info to request for downstream handlers
// (always populate when a valid session exists, even on public routes)
(request as FastifyRequest & { user?: AuthUser }).user = {