Initial commit: Kalendartool (Next.js, Prisma, Docker)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
69
lib/auth/hub-provisioning.ts
Normal file
69
lib/auth/hub-provisioning.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* JIT-Provisioning (platform-plan.md §3.2):
|
||||
* Koppelt einen Hub-User an einen lokalen Kalendartool-Account.
|
||||
*
|
||||
* - Existiert bereits ein Account mit dieser hubId → zurückgeben.
|
||||
* - Existiert ein Account mit gleicher E-Mail (lokaler Login) → koppeln
|
||||
* (hubId setzen), Passwort/Rolle bleiben unangetastet.
|
||||
* - Sonst → neuen Account anlegen (Default-Rolle USER, kein Passwort).
|
||||
*
|
||||
* Bewusst KEINE Rollen-Synchronisation: Der Kalendertool-Admin verwaltet
|
||||
* Rollen weiterhin selbst (Hub entscheidet nur über Tool-Zugriff).
|
||||
*/
|
||||
import { prisma } from "@/lib/db/client";
|
||||
import type { AuthenticatedUser } from "@/lib/permissions/permissions";
|
||||
import type { Role } from "@prisma/client";
|
||||
|
||||
export interface HubIdentity {
|
||||
hubId: string;
|
||||
email: string;
|
||||
name: string;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
export async function provisionHubUser(identity: HubIdentity): Promise<AuthenticatedUser> {
|
||||
// 1. Bereits gekoppelt?
|
||||
const byHubId = await prisma.user.findUnique({ where: { hubId: identity.hubId } });
|
||||
if (byHubId) {
|
||||
return {
|
||||
id: byHubId.id,
|
||||
email: byHubId.email,
|
||||
username: byHubId.username,
|
||||
role: byHubId.role,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Gleiche E-Mail vorhanden (lokaler Account) → koppeln.
|
||||
const byEmail = await prisma.user.findUnique({ where: { email: identity.email } });
|
||||
if (byEmail) {
|
||||
const linked = await prisma.user.update({
|
||||
where: { id: byEmail.id },
|
||||
data: { hubId: identity.hubId },
|
||||
});
|
||||
return {
|
||||
id: linked.id,
|
||||
email: linked.email,
|
||||
username: linked.username,
|
||||
role: linked.role,
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Neuen Account anlegen (JIT). Kein Passwort – Login nur via Hub.
|
||||
const created = await prisma.user.create({
|
||||
data: {
|
||||
email: identity.email,
|
||||
username: identity.name || null,
|
||||
// passwordHash ist NOT NULL → nicht ratbares Zufallspasswort.
|
||||
passwordHash: crypto.randomUUID() + crypto.randomUUID(),
|
||||
role: "USER" as Role,
|
||||
hubId: identity.hubId,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: created.id,
|
||||
email: created.email,
|
||||
username: created.username,
|
||||
role: created.role,
|
||||
};
|
||||
}
|
||||
79
lib/auth/hub.ts
Normal file
79
lib/auth/hub.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* JWKS-Client: lädt die öffentlichen Hub-Schlüssel und cached sie.
|
||||
* Tools validieren Hub-Tokens gegen diesen Key (RS256) – kein geteiltes Secret.
|
||||
*
|
||||
* platform-plan.md §5.2: Der Hub exposet /.well-known/jwks.json.
|
||||
*/
|
||||
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
|
||||
import { getConfig } from "@/lib/config";
|
||||
|
||||
const JWKS_CACHE_TTL_MS = 5 * 60 * 1000; // 5 Minuten (passend zum Hub-Cache-Header)
|
||||
|
||||
interface CachedJwks {
|
||||
jwks: ReturnType<typeof createRemoteJWKSet>;
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
let cache: CachedJwks | null = null;
|
||||
|
||||
function getHubUrl(): string {
|
||||
const hubUrl = getConfig().hubUrl;
|
||||
if (!hubUrl) {
|
||||
throw new Error("HUB_URL fehlt – Hub-SSO ist nicht konfiguriert.");
|
||||
}
|
||||
return hubUrl.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
/** Remote-JWKSet mit einfachem TTL-Cache (jose hat keinen eingebauten). */
|
||||
function getJwksSet(): ReturnType<typeof createRemoteJWKSet> {
|
||||
const now = Date.now();
|
||||
if (cache && now - cache.fetchedAt < JWKS_CACHE_TTL_MS) {
|
||||
return cache.jwks;
|
||||
}
|
||||
const jwks = createRemoteJWKSet(new URL(`${getHubUrl()}/.well-known/jwks.json`));
|
||||
cache = { jwks, fetchedAt: now };
|
||||
return jwks;
|
||||
}
|
||||
|
||||
export interface HubTokenResult {
|
||||
hubId: string;
|
||||
email: string;
|
||||
name: string;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validiert ein Hub-Token (Signatur, TTL, aud, iss).
|
||||
* Wirft bei jedem Fehler – der Aufrufer entscheidet über die Response.
|
||||
*/
|
||||
export async function verifyHubToken(token: string): Promise<HubTokenResult> {
|
||||
const config = getConfig();
|
||||
const expectedAud = config.hubToolSlug;
|
||||
if (!expectedAud) {
|
||||
throw new Error("HUB_TOOL_SLUG fehlt – Tool-Slug für Hub-SSO nicht konfiguriert.");
|
||||
}
|
||||
|
||||
const { payload } = await jwtVerify(token, getJwksSet(), {
|
||||
algorithms: ["RS256"],
|
||||
audience: expectedAud,
|
||||
issuer: config.hubIssuer ?? "http://localhost:3001",
|
||||
clockTolerance: 5,
|
||||
});
|
||||
|
||||
const hubId = payload.sub;
|
||||
const email = payload.email;
|
||||
const name = payload.name;
|
||||
if (typeof hubId !== "string" || typeof email !== "string" || typeof name !== "string") {
|
||||
throw new Error("Hub-Token enthält unvollständige Claims.");
|
||||
}
|
||||
|
||||
return {
|
||||
hubId,
|
||||
email,
|
||||
name,
|
||||
isAdmin: payload.isAdmin === true,
|
||||
};
|
||||
}
|
||||
|
||||
/** Nur für Typ-Export (jose JWTPayload) – nicht Teil des Contracts. */
|
||||
export type { JWTPayload };
|
||||
21
lib/auth/password.ts
Normal file
21
lib/auth/password.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Passwort-Hashing mit bcrypt (plan.md Abschnitt 11:
|
||||
* "Passwoerter niemals selbst verschluesseln oder im Klartext speichern").
|
||||
*/
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
/** Kostenfaktor: 12 Runden sind aktueller Standard (ca. 200-300 ms). */
|
||||
const BCRYPT_ROUNDS = 12;
|
||||
|
||||
/** Hasht ein Klartext-Passwort fuer die Speicherung in der Datenbank. */
|
||||
export async function hashPassword(plainPassword: string): Promise<string> {
|
||||
return bcrypt.hash(plainPassword, BCRYPT_ROUNDS);
|
||||
}
|
||||
|
||||
/** Prueft ein Klartext-Passwort gegen den gespeicherten Hash. */
|
||||
export async function verifyPassword(
|
||||
plainPassword: string,
|
||||
passwordHash: string,
|
||||
): Promise<boolean> {
|
||||
return bcrypt.compare(plainPassword, passwordHash);
|
||||
}
|
||||
101
lib/auth/session.ts
Normal file
101
lib/auth/session.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user