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,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');
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user