Files
app/src/routes/admin/sessions.ts
BOHA 744baec2ec feat(security): audit-log session termination (single + all-others)
The two session-revocation endpoints wrote no audit entry. Added a 'session' EntityType (+ Czech label 'Relace') and logAudit on both deletes, so revoking sessions is recorded in the forensic trail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 02:40:41 +02:00

145 lines
4.6 KiB
TypeScript

import { FastifyInstance } from "fastify";
import prisma from "../../config/database";
import { requireAuth } from "../../middleware/auth";
import { success, error, parseId } from "../../utils/response";
import { hashToken } from "../../services/auth";
import { logAudit } from "../../services/audit";
/** Parse user-agent string into browser, OS, and device icon */
function parseUserAgent(ua: string | null): {
browser: string;
os: string;
icon: string;
} {
if (!ua)
return {
browser: "Neznámý prohlížeč",
os: "Neznámý systém",
icon: "monitor",
};
let browser = "Neznámý prohlížeč";
if (ua.includes("Edg/")) browser = "Edge";
else if (ua.includes("OPR/") || ua.includes("Opera")) browser = "Opera";
else if (ua.includes("Chrome/")) browser = "Chrome";
else if (ua.includes("Safari/") && !ua.includes("Chrome")) browser = "Safari";
else if (ua.includes("Firefox/")) browser = "Firefox";
let os = "Neznámý systém";
if (ua.includes("Windows")) os = "Windows";
else if (ua.includes("Mac OS X") || ua.includes("Macintosh")) os = "macOS";
else if (ua.includes("Linux") && !ua.includes("Android")) os = "Linux";
else if (ua.includes("Android")) os = "Android";
else if (ua.includes("iPhone") || ua.includes("iPad")) os = "iOS";
let icon = "monitor";
if (
ua.includes("Mobile") ||
ua.includes("iPhone") ||
ua.includes("Android")
) {
icon =
ua.includes("iPad") || ua.includes("Tablet") ? "tablet" : "smartphone";
}
return { browser, os, icon };
}
export default async function sessionsRoutes(
fastify: FastifyInstance,
): Promise<void> {
// GET /api/admin/sessions — list active sessions for current user
fastify.get("/", { preHandler: requireAuth }, async (request, reply) => {
const authData = request.authData!;
const currentToken = request.cookies?.refresh_token;
const currentHash = currentToken ? hashToken(currentToken) : null;
const sessions = await prisma.refresh_tokens.findMany({
where: {
user_id: authData.userId,
replaced_at: null,
expires_at: { gt: new Date() },
},
orderBy: { created_at: "desc" },
});
const enriched = sessions.map((s) => {
const device_info = parseUserAgent(s.user_agent);
return {
id: s.id,
is_current: currentHash ? s.token_hash === currentHash : false,
device_info,
ip_address: s.ip_address || "",
created_at: s.created_at ?? "",
};
});
return success(reply, enriched);
});
// DELETE /api/admin/sessions/:id — delete specific session
fastify.delete<{ Params: { id: string } }>(
"/:id",
{ preHandler: requireAuth },
async (request, reply) => {
const id = parseId((request.params as any).id, reply);
if (id === null) return;
const authData = request.authData!;
const session = await prisma.refresh_tokens.findFirst({
where: { id, user_id: authData.userId },
});
if (!session) return error(reply, "Relace nenalezena", 404);
await prisma.refresh_tokens.update({
where: { id },
data: { replaced_at: new Date() },
});
await logAudit({
request,
authData,
action: "delete",
entityType: "session",
entityId: id,
description: `Ukončena relace #${id} (${session.ip_address ?? "?"})`,
});
return success(reply, null, 200, "Relace ukončena");
},
);
// DELETE /api/admin/sessions — delete all sessions except current
fastify.delete("/", { preHandler: requireAuth }, async (request, reply) => {
const authData = request.authData!;
const query = request.query as Record<string, unknown>;
if (query.action === "all") {
// Get current token from cookie to exclude (hash it to match stored token_hash)
const currentToken = request.cookies?.refresh_token;
const currentHash = currentToken ? hashToken(currentToken) : null;
if (!currentHash) {
return error(reply, "Nelze identifikovat aktuální relaci", 400);
}
const terminated = await prisma.refresh_tokens.updateMany({
where: {
user_id: authData.userId,
replaced_at: null,
token_hash: { not: currentHash },
},
data: { replaced_at: new Date() },
});
await logAudit({
request,
authData,
action: "delete",
entityType: "session",
description: `Ukončeny všechny ostatní relace (${terminated.count})`,
});
return success(reply, null, 200, "Všechny ostatní relace ukončeny");
}
return error(reply, "Neplatná akce", 400);
});
}