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:
2026-09-18 11:33:25 +02:00
parent a1b764155a
commit 3d2b87297e
7 changed files with 336 additions and 0 deletions
+72
View File
@@ -0,0 +1,72 @@
import type { FastifyReply, FastifyRequest, preHandlerHookHandler } from 'fastify';
import type { Role } from '@prisma/client';
import { forbidden, unauthorized } from '../errors.js';
import { CSRF_HEADER, SESSION_COOKIE, csrfMatches, resolveSession } from './session.js';
import type { SessionUser } from './session.js';
declare module 'fastify' {
interface FastifyRequest {
user?: SessionUser;
sessionToken?: string;
}
}
const RANK: Record<Role, number> = { VIEWER: 1, EDITOR: 2, ADMIN: 3 };
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
// Ohne diese Ausnahme koennte sich niemand anmelden: vor dem Login gibt es
// weder Session noch ableitbares CSRF-Token.
const CSRF_EXEMPT = new Set(['/api/auth/login', '/api/auth/login/totp']);
export async function attachUser(request: FastifyRequest): Promise<void> {
const token = request.cookies[SESSION_COOKIE];
if (!token) return;
const user = await resolveSession(token);
if (user) {
request.user = user;
request.sessionToken = token;
}
}
export async function enforceCsrf(request: FastifyRequest, _reply: FastifyReply): Promise<void> {
if (SAFE_METHODS.has(request.method)) return;
if (CSRF_EXEMPT.has(request.url.split('?')[0] ?? '')) return;
const token = request.cookies[SESSION_COOKIE];
if (!token) return; // Ohne Session greift ohnehin requireAuth.
const header = request.headers[CSRF_HEADER];
const presented = Array.isArray(header) ? header[0] : header;
if (!csrfMatches(token, presented)) {
throw forbidden('CSRF-Token fehlt oder passt nicht');
}
}
export const requireAuth: preHandlerHookHandler = async (request) => {
if (!request.user) throw unauthorized();
};
export function requireRole(minimum: Role): preHandlerHookHandler {
return async (request) => {
if (!request.user) throw unauthorized();
if (RANK[request.user.role] < RANK[minimum]) {
throw forbidden();
}
};
}
// Solange ein Erstpasswort nicht gewechselt ist, bleiben schreibende Wege zu.
export const blockUntilPasswordChanged: preHandlerHookHandler = async (request) => {
if (request.user?.mustChangePassword && !SAFE_METHODS.has(request.method)) {
const path = request.url.split('?')[0] ?? '';
if (path !== '/api/account/password' && path !== '/api/auth/logout') {
throw forbidden('Bitte zuerst das Passwort aendern');
}
}
};
export const requireEditor = requireRole('EDITOR');
export const requireAdmin = requireRole('ADMIN');
+36
View File
@@ -0,0 +1,36 @@
import { hash, verify } from '@node-rs/argon2';
// OWASP-Empfehlung fuer Argon2id: 19 MiB, 2 Durchlaeufe, Parallelitaet 1.
const options = {
memoryCost: 19456,
timeCost: 2,
parallelism: 1,
} as const;
export function hashPassword(plain: string): Promise<string> {
return hash(plain, options);
}
export async function verifyPassword(digest: string, plain: string): Promise<boolean> {
try {
return await verify(digest, plain, options);
} catch {
// Kaputter oder fremdformatiger Hash gilt als nicht passend.
return false;
}
}
const MIN_LENGTH = 12;
export function validatePasswordStrength(plain: string): string | null {
if (plain.length < MIN_LENGTH) {
return `Passwort muss mindestens ${MIN_LENGTH} Zeichen lang sein`;
}
if (!/[a-z]/.test(plain) || !/[A-Z]/.test(plain)) {
return 'Passwort muss Gross- und Kleinbuchstaben enthalten';
}
if (!/[0-9]/.test(plain)) {
return 'Passwort muss mindestens eine Ziffer enthalten';
}
return null;
}
+115
View File
@@ -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;
}
+33
View File
@@ -0,0 +1,33 @@
import { generateSecret as createSecret, generateURI, verify } from 'otplib';
import QRCode from 'qrcode';
const ISSUER = 'CC-Manager';
// Toleranz in Sekunden. Ein Zeitschritt in beide Richtungen faengt leicht
// abweichende Uhren zwischen Server und Authenticator-App ab.
const EPOCH_TOLERANCE_SECONDS = 30;
export function generateSecret(): string {
return createSecret();
}
export async function verifyTotp(secret: string, token: string): Promise<boolean> {
try {
const result = await verify({
secret,
token: token.replace(/\s/g, ''),
epochTolerance: EPOCH_TOLERANCE_SECONDS,
});
return result.valid;
} catch {
return false;
}
}
export function otpauthUrl(username: string, secret: string): string {
return generateURI({ issuer: ISSUER, label: username, secret });
}
export function qrDataUrl(otpauth: string): Promise<string> {
return QRCode.toDataURL(otpauth, { margin: 1, width: 240 });
}
+12
View File
@@ -0,0 +1,12 @@
import { PrismaClient } from '@prisma/client';
import { env } from './env.js';
export const prisma = new PrismaClient({
log: env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error'],
});
export type Tx = Parameters<Parameters<PrismaClient['$transaction']>[0]>[0];
export async function disconnect(): Promise<void> {
await prisma.$disconnect();
}
+50
View File
@@ -0,0 +1,50 @@
import { z } from 'zod';
const schema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().positive().default(3000),
HOST: z.string().default('0.0.0.0'),
DATABASE_URL: z.string().min(1, 'DATABASE_URL fehlt'),
SESSION_SECRET: z.string().min(32, 'SESSION_SECRET muss mindestens 32 Zeichen lang sein'),
// Lebensdauer einer Session in Stunden.
SESSION_TTL_HOURS: z.coerce.number().int().positive().default(12),
// Wird beim ersten Start angelegt, solange noch kein Account existiert.
BOOTSTRAP_ADMIN_USER: z.string().min(3).default('admin'),
BOOTSTRAP_ADMIN_PASSWORD: z.string().min(12).optional(),
// Hinter einem Reverse Proxy noetig, damit Client-IPs im Audit-Log stimmen.
TRUST_PROXY: z
.string()
.default('true')
.transform((v) => v === 'true' || v === '1'),
// Muss in Produktion true sein (Cookies nur ueber HTTPS).
COOKIE_SECURE: z
.string()
.optional()
.transform((v) => (v === undefined ? undefined : v === 'true' || v === '1')),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
});
const parsed = schema.safeParse(process.env);
if (!parsed.success) {
const issues = parsed.error.issues.map((i) => ` - ${i.path.join('.')}: ${i.message}`).join('\n');
console.error(`Konfiguration unvollstaendig:\n${issues}`);
process.exit(1);
}
const raw = parsed.data;
export const env = {
...raw,
isProduction: raw.NODE_ENV === 'production',
// In Produktion ohne expliziten Wert: sichere Cookies erzwingen.
cookieSecure: raw.COOKIE_SECURE ?? raw.NODE_ENV === 'production',
};
export type Env = typeof env;
+18
View File
@@ -0,0 +1,18 @@
export class AppError extends Error {
constructor(
readonly statusCode: number,
readonly code: string,
message: string,
) {
super(message);
this.name = 'AppError';
}
}
export const badRequest = (code: string, message: string) => new AppError(400, code, message);
export const unauthorized = (message = 'Nicht angemeldet') =>
new AppError(401, 'UNAUTHORIZED', message);
export const forbidden = (message = 'Keine Berechtigung fuer diese Aktion') =>
new AppError(403, 'FORBIDDEN', message);
export const notFound = (what: string) => new AppError(404, 'NOT_FOUND', `${what} nicht gefunden`);
export const conflict = (code: string, message: string) => new AppError(409, code, message);