Authentifizierung mit serverseitigen Sitzungen, CSRF und Rollen
Passwoerter mit Argon2id, im Cookie steht nur ein Token und in der Datenbank dessen Hash. Das CSRF-Token wird per HMAC aus dem Sitzungstoken abgeleitet statt separat gespeichert - ohne das httpOnly-Cookie ist es nicht herstellbar. TOTP ist optional und wird erst nach einem bestaetigten Code scharf geschaltet, damit sich niemand bei der Einrichtung aussperrt.
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
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 ausser 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;
|
||||
}
|
||||
Reference in New Issue
Block a user