Files
CC-Manager/server/src/auth/session.ts
T
Harlekin 724f68644b Umlaute statt Umschreibungen
Oberflaeche, Fehlermeldungen und Kommentare schrieben durchgehend ae, oe, ue
und ss. Dafuer gab es keinen Grund - die Anwendung ist deutschsprachig und
laeuft durchgehend in UTF-8.

Betroffen waren 55 Dateien, darunter alle sichtbaren Texte: Guthaben-Zaehler,
Import, Nutzerverwaltung, Passwortwechsel und die Zwei-Faktor-Einrichtung.

Der Name einer Migration bleibt im README-Beispiel bewusst bei ASCII, weil
daraus ein Verzeichnisname wird.
2026-09-18 16:57:39 +02:00

116 lines
3.5 KiB
TypeScript

import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import type { Role, User } from '@prisma/client';
import { prisma } from '../db.js';
import { env } from '../env.js';
export const SESSION_COOKIE = 'ccm_sid';
export const CSRF_COOKIE = 'ccm_csrf';
export const CSRF_HEADER = 'x-csrf-token';
export interface SessionUser {
id: number;
username: string;
role: Role;
mustChangePassword: boolean;
totpEnabled: boolean;
}
function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
// Das CSRF-Token wird aus dem Session-Token abgeleitet statt separat gespeichert.
// Ohne Kenntnis des httpOnly-Cookies und des SESSION_SECRET ist es nicht herstellbar.
export function csrfTokenFor(sessionToken: string): string {
return createHmac('sha256', env.SESSION_SECRET).update(sessionToken).digest('hex');
}
export function csrfMatches(sessionToken: string, presented: string | undefined): boolean {
if (!presented) return false;
const expected = csrfTokenFor(sessionToken);
const a = Buffer.from(expected);
const b = Buffer.from(presented);
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
export function toSessionUser(user: User): SessionUser {
return {
id: user.id,
username: user.username,
role: user.role,
mustChangePassword: user.mustChangePassword,
totpEnabled: user.totpEnabled,
};
}
export async function createSession(
userId: number,
ip: string | undefined,
userAgent: string | undefined,
): Promise<{ token: string; expiresAt: Date }> {
const token = randomBytes(32).toString('base64url');
const expiresAt = new Date(Date.now() + env.SESSION_TTL_HOURS * 3600 * 1000);
await prisma.session.create({
data: {
tokenHash: hashToken(token),
userId,
expiresAt,
ip: ip ?? null,
userAgent: userAgent?.slice(0, 255) ?? null,
},
});
return { token, expiresAt };
}
export async function resolveSession(token: string): Promise<SessionUser | null> {
const session = await prisma.session.findUnique({
where: { tokenHash: hashToken(token) },
include: { user: true },
});
if (!session) return null;
if (session.expiresAt.getTime() < Date.now()) {
await prisma.session.delete({ where: { id: session.id } }).catch(() => undefined);
return null;
}
if (!session.user.isActive) {
await prisma.session.deleteMany({ where: { userId: session.userId } });
return null;
}
// Nicht bei jedem Request schreiben, sonst erzeugt jede Anfrage einen Write.
const staleAfter = 5 * 60 * 1000;
if (Date.now() - session.lastSeenAt.getTime() > staleAfter) {
await prisma.session
.update({ where: { id: session.id }, data: { lastSeenAt: new Date() } })
.catch(() => undefined);
}
return toSessionUser(session.user);
}
export async function destroySession(token: string): Promise<void> {
await prisma.session.deleteMany({ where: { tokenHash: hashToken(token) } });
}
export async function destroyAllSessionsFor(userId: number): Promise<void> {
await prisma.session.deleteMany({ where: { userId } });
}
/** Beendet alle Sitzungen des Nutzers außer der gerade verwendeten. */
export async function destroyOtherSessionsFor(userId: number, keepToken: string): Promise<void> {
await prisma.session.deleteMany({
where: { userId, NOT: { tokenHash: hashToken(keepToken) } },
});
}
export async function cleanupExpiredSessions(): Promise<number> {
const result = await prisma.session.deleteMany({ where: { expiresAt: { lt: new Date() } } });
return result.count;
}