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.
116 lines
3.5 KiB
TypeScript
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;
|
|
}
|