From 3d2b87297ed1019e35c691052f8036079f89db34 Mon Sep 17 00:00:00 2001 From: Harlekin Date: Fri, 18 Sep 2026 11:33:25 +0200 Subject: [PATCH] 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. --- server/src/auth/guard.ts | 72 ++++++++++++++++++++++ server/src/auth/password.ts | 36 +++++++++++ server/src/auth/session.ts | 115 ++++++++++++++++++++++++++++++++++++ server/src/auth/totp.ts | 33 +++++++++++ server/src/db.ts | 12 ++++ server/src/env.ts | 50 ++++++++++++++++ server/src/errors.ts | 18 ++++++ 7 files changed, 336 insertions(+) create mode 100644 server/src/auth/guard.ts create mode 100644 server/src/auth/password.ts create mode 100644 server/src/auth/session.ts create mode 100644 server/src/auth/totp.ts create mode 100644 server/src/db.ts create mode 100644 server/src/env.ts create mode 100644 server/src/errors.ts diff --git a/server/src/auth/guard.ts b/server/src/auth/guard.ts new file mode 100644 index 0000000..20c9c74 --- /dev/null +++ b/server/src/auth/guard.ts @@ -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 = { 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 { + 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 { + 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'); diff --git a/server/src/auth/password.ts b/server/src/auth/password.ts new file mode 100644 index 0000000..8236069 --- /dev/null +++ b/server/src/auth/password.ts @@ -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 { + return hash(plain, options); +} + +export async function verifyPassword(digest: string, plain: string): Promise { + 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; +} diff --git a/server/src/auth/session.ts b/server/src/auth/session.ts new file mode 100644 index 0000000..a40defd --- /dev/null +++ b/server/src/auth/session.ts @@ -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 { + 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 { + await prisma.session.deleteMany({ where: { tokenHash: hashToken(token) } }); +} + +export async function destroyAllSessionsFor(userId: number): Promise { + await prisma.session.deleteMany({ where: { userId } }); +} + +/** Beendet alle Sitzungen des Nutzers ausser der gerade verwendeten. */ +export async function destroyOtherSessionsFor(userId: number, keepToken: string): Promise { + await prisma.session.deleteMany({ + where: { userId, NOT: { tokenHash: hashToken(keepToken) } }, + }); +} + +export async function cleanupExpiredSessions(): Promise { + const result = await prisma.session.deleteMany({ where: { expiresAt: { lt: new Date() } } }); + return result.count; +} diff --git a/server/src/auth/totp.ts b/server/src/auth/totp.ts new file mode 100644 index 0000000..706de62 --- /dev/null +++ b/server/src/auth/totp.ts @@ -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 { + 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 { + return QRCode.toDataURL(otpauth, { margin: 1, width: 240 }); +} diff --git a/server/src/db.ts b/server/src/db.ts new file mode 100644 index 0000000..4d1754f --- /dev/null +++ b/server/src/db.ts @@ -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[0]>[0]; + +export async function disconnect(): Promise { + await prisma.$disconnect(); +} diff --git a/server/src/env.ts b/server/src/env.ts new file mode 100644 index 0000000..8b4318e --- /dev/null +++ b/server/src/env.ts @@ -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; diff --git a/server/src/errors.ts b/server/src/errors.ts new file mode 100644 index 0000000..88e5955 --- /dev/null +++ b/server/src/errors.ts @@ -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);