Initial commit: Kalendartool (Next.js, Prisma, Docker)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Kühn
2026-10-08 14:38:04 +02:00
commit bc49c3074e
94 changed files with 12812 additions and 0 deletions

101
lib/auth/session.ts Normal file
View File

@@ -0,0 +1,101 @@
/**
* Session-Management (plan.md Abschnitt 11).
*
* Signierte, HttpOnly-Cookies auf Basis von JWT (jose). Keine eigene
* Kryptografie, keine Klartext-Sessions in der Datenbank.
*/
import { SignJWT, jwtVerify } from 'jose';
import { cookies } from 'next/headers';
import { getConfig } from '@/lib/config';
import type { AuthenticatedUser } from '@/lib/permissions/permissions';
const SESSION_COOKIE_NAME = 'calendar_session';
const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 7; // 7 Tage
/** Fehler bei ungueltiger/abgelaufener Session. */
export class UnauthorizedError extends Error {
constructor() {
super('Nicht angemeldet oder Sitzung abgelaufen.');
this.name = 'UnauthorizedError';
}
}
async function getSessionKey(): Promise<Uint8Array> {
const secret = getConfig().sessionSecret;
return new TextEncoder().encode(secret);
}
/** Erstellt ein signiertes Session-Token fuer den Benutzer. */
export async function createSessionToken(user: {
id: string;
email: string;
role: string;
}): Promise<string> {
const key = await getSessionKey();
return new SignJWT({ sub: user.id, email: user.email, role: user.role })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(`${SESSION_MAX_AGE_SECONDS}s`)
.sign(key);
}
/** Setzt das Session-Cookie sicher (HttpOnly, SameSite=Lax, Secure in Prod). */
export async function setSessionCookie(token: string): Promise<void> {
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE_NAME, token, {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
maxAge: SESSION_MAX_AGE_SECONDS,
path: '/',
});
}
/** Loescht das Session-Cookie (Logout). */
export async function clearSessionCookie(): Promise<void> {
const cookieStore = await cookies();
cookieStore.delete(SESSION_COOKIE_NAME);
}
/**
* Liest und verifiziert die aktuelle Session.
* Wirft UnauthorizedError, wenn keine gueltige Session existiert.
*/
export async function getAuthenticatedUser(): Promise<AuthenticatedUser> {
const cookieStore = await cookies();
const token = cookieStore.get(SESSION_COOKIE_NAME)?.value;
if (!token) {
throw new UnauthorizedError();
}
try {
const { payload } = await jwtVerify(token, await getSessionKey());
if (typeof payload.sub !== 'string' || typeof payload.role !== 'string') {
throw new UnauthorizedError();
}
// Alte Tokens (vor E-Mail-Erweiterung) enthalten keine E-Mail;
// in dem Fall wird ein Platzhalter verwendet, requireDbUser laedt
// ohnehin die frischen Daten aus der DB.
const email =
typeof payload.email === 'string' ? payload.email : 'unbekannt@lokal';
// Username ist nicht im JWT (kann sich aendern); requireDbUser
// laedt den aktuellen Wert aus der DB. Das JWT liefert null als
// Platzhalter, damit der Typ erfuellt ist.
return {
id: payload.sub,
email,
username: null,
role: payload.role as AuthenticatedUser['role'],
};
} catch {
throw new UnauthorizedError();
}
}
/** Wie getAuthenticatedUser, gibt aber null statt zu werfen. */
export async function tryGetAuthenticatedUser(): Promise<AuthenticatedUser | null> {
try {
return await getAuthenticatedUser();
} catch {
return null;
}
}