diff --git a/src/app/(auth)/actions.ts b/src/app/(auth)/actions.ts new file mode 100644 index 0000000..f03c52e --- /dev/null +++ b/src/app/(auth)/actions.ts @@ -0,0 +1,118 @@ +"use server"; + +import { redirect } from "next/navigation"; +import { z } from "zod"; +import { prisma } from "@/lib/db"; +import { isRegistrationOpen } from "@/lib/settings"; +import { + checkPasswordStrength, + createSession, + hashPassword, + verifyPassword, +} from "@/lib/auth"; + +export type AuthFormState = { error?: string }; + +const loginSchema = z.object({ + email: z.string().trim().toLowerCase().email(), + password: z.string().min(1), +}); + +const registerSchema = z.object({ + name: z.string().trim().min(1).max(80), + email: z.string().trim().toLowerCase().email(), + password: z.string().min(1), + passwordRepeat: z.string().min(1), +}); + +/** + * Anmeldung. + * + * Fehlermeldungen sind bewusst unspezifisch („E-Mail oder Passwort stimmt + * nicht"), damit sich über das Formular nicht herausfinden lässt, welche + * Adressen ein Konto haben. + */ +export async function loginAction( + _prev: AuthFormState, + formData: FormData, +): Promise { + const parsed = loginSchema.safeParse({ + email: formData.get("email"), + password: formData.get("password"), + }); + if (!parsed.success) { + return { error: "E-Mail-Adresse oder Passwort stimmt nicht." }; + } + + const user = await prisma.user.findUnique({ + where: { email: parsed.data.email }, + select: { id: true, passwordHash: true, isActive: true }, + }); + + // Auch ohne Treffer einmal hashen, damit die Antwortzeit nichts verrät. + const hash = user?.passwordHash ?? "$2a$12$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvalidin"; + const ok = await verifyPassword(parsed.data.password, hash); + + if (!user || !user.passwordHash || !ok) { + return { error: "E-Mail-Adresse oder Passwort stimmt nicht." }; + } + if (!user.isActive) { + return { error: "Dieses Konto ist deaktiviert." }; + } + + await createSession(user.id); + + const target = String(formData.get("weiter") ?? "/"); + redirect(target.startsWith("/") && !target.startsWith("//") ? target : "/"); +} + +/** + * Registrierung. Der allererste Zugang wird automatisch Administrator — + * sonst käme man nach dem Aufsetzen nicht in den Verwaltungsbereich. + */ +export async function registerAction( + _prev: AuthFormState, + formData: FormData, +): Promise { + if (!(await isRegistrationOpen())) { + return { error: "Die Registrierung ist auf dieser Instanz deaktiviert." }; + } + + const parsed = registerSchema.safeParse({ + name: formData.get("name"), + email: formData.get("email"), + password: formData.get("password"), + passwordRepeat: formData.get("passwordRepeat"), + }); + if (!parsed.success) { + return { error: "Bitte fülle alle Felder korrekt aus." }; + } + + const { name, email, password, passwordRepeat } = parsed.data; + + if (password !== passwordRepeat) { + return { error: "Die beiden Passwörter stimmen nicht überein." }; + } + const weak = checkPasswordStrength(password); + if (weak) return { error: weak }; + + const existing = await prisma.user.findUnique({ where: { email }, select: { id: true } }); + if (existing) { + return { error: "Für diese E-Mail-Adresse gibt es bereits ein Konto." }; + } + + const isFirstUser = (await prisma.user.count()) === 0; + + const user = await prisma.user.create({ + data: { + name, + email, + passwordHash: await hashPassword(password), + role: isFirstUser ? "ADMIN" : "USER", + }, + select: { id: true }, + }); + + await createSession(user.id); + redirect("/"); +} diff --git a/src/app/(auth)/anmelden/page.tsx b/src/app/(auth)/anmelden/page.tsx new file mode 100644 index 0000000..01a8bfd --- /dev/null +++ b/src/app/(auth)/anmelden/page.tsx @@ -0,0 +1,61 @@ +import { redirect } from "next/navigation"; +import type { Metadata } from "next"; +import { getSessionUser } from "@/lib/auth"; +import { getSettings, isRegistrationOpen } from "@/lib/settings"; +import { env } from "@/lib/env"; +import { AuthSwitch, LoginForm } from "@/components/AuthForm"; +import { loginAction } from "../actions"; + +export const metadata: Metadata = { title: "Anmelden" }; + +export default async function LoginPage({ + searchParams, +}: { + searchParams: Promise<{ weiter?: string; fehler?: string }>; +}) { + if (await getSessionUser()) redirect("/"); + + const params = await searchParams; + const [settings, registrationOpen] = await Promise.all([getSettings(), isRegistrationOpen()]); + + return ( +
+

Willkommen zurück

+

+ Melde dich an, um weiterzuschauen. +

+ + {params.fehler && ( +

+ {params.fehler} +

+ )} + + + + {env.oidc.isConfigured && ( + <> +
+ + oder + +
+ + {env.oidc.buttonLabel} + + + )} + + {registrationOpen && ( +
+ Noch kein Konto? Jetzt registrieren +
+ )} + +

{settings.instanceName}

+
+ ); +} diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx new file mode 100644 index 0000000..85870da --- /dev/null +++ b/src/app/(auth)/layout.tsx @@ -0,0 +1,29 @@ +import Link from "next/link"; +import { FlimmerLogo } from "@/components/brand/FlimmerLogo"; +import { ThemeToggle } from "@/components/ThemeToggle"; +import pkg from "../../../package.json"; + +/** + * Rahmen für Anmeldung und Registrierung: ruhige, mittige Karte vor dem + * warmen Hintergrundglühen — dasselbe Bild wie im Logo. + */ +export default function AuthLayout({ children }: { children: React.ReactNode }) { + return ( +
+
+ + + + +
+ +
+
{children}
+
+ +
+ Flimmer {pkg.version} · selbst gehostet +
+
+ ); +} diff --git a/src/app/(auth)/registrieren/page.tsx b/src/app/(auth)/registrieren/page.tsx new file mode 100644 index 0000000..a7e6492 --- /dev/null +++ b/src/app/(auth)/registrieren/page.tsx @@ -0,0 +1,48 @@ +import { redirect } from "next/navigation"; +import type { Metadata } from "next"; +import { getSessionUser } from "@/lib/auth"; +import { isRegistrationOpen } from "@/lib/settings"; +import { prisma } from "@/lib/db"; +import { AuthSwitch, RegisterForm } from "@/components/AuthForm"; +import { registerAction } from "../actions"; + +export const metadata: Metadata = { title: "Registrieren" }; + +export default async function RegisterPage() { + if (await getSessionUser()) redirect("/"); + + const [open, userCount] = await Promise.all([isRegistrationOpen(), prisma.user.count()]); + const isFirstUser = userCount === 0; + + if (!open) { + return ( +
+

Registrierung geschlossen

+

+ Auf dieser Instanz können sich keine neuen Konten anlegen. Wende dich an die + Administration, wenn du einen Zugang brauchst. +

+
+ Zur Anmeldung +
+
+ ); + } + + return ( +
+

Konto anlegen

+

+ {isFirstUser + ? "Du bist die erste Person hier — dieses Konto wird automatisch Administrator." + : "Lege dir deine eigene Bibliothek an."} +

+ + + +
+ Schon ein Konto? Zur Anmeldung +
+
+ ); +} diff --git a/src/app/api/auth/abmelden/route.ts b/src/app/api/auth/abmelden/route.ts new file mode 100644 index 0000000..bc465a7 --- /dev/null +++ b/src/app/api/auth/abmelden/route.ts @@ -0,0 +1,9 @@ +import { NextResponse } from "next/server"; +import { destroySession } from "@/lib/auth"; +import { env } from "@/lib/env"; + +/** Abmelden per POST — GET würde sonst durch Link-Prefetching ausgelöst. */ +export async function POST() { + await destroySession(); + return NextResponse.redirect(new URL("/anmelden", env.appUrl), { status: 303 }); +} diff --git a/src/app/api/auth/oidc/callback/route.ts b/src/app/api/auth/oidc/callback/route.ts new file mode 100644 index 0000000..04c5d3d --- /dev/null +++ b/src/app/api/auth/oidc/callback/route.ts @@ -0,0 +1,99 @@ +import { NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import { prisma } from "@/lib/db"; +import { env } from "@/lib/env"; +import { createSession } from "@/lib/auth"; +import { exchangeCode } from "@/lib/oidc"; + +export const dynamic = "force-dynamic"; + +function fail(message: string) { + return NextResponse.redirect( + new URL(`/anmelden?fehler=${encodeURIComponent(message)}`, env.appUrl), + ); +} + +/** Rücksprung vom Anbieter: Code einlösen, Konto zuordnen, Sitzung anlegen. */ +export async function GET(request: Request) { + if (!env.oidc.isConfigured) return fail("SSO ist auf dieser Instanz nicht eingerichtet."); + + const url = new URL(request.url); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const providerError = url.searchParams.get("error"); + + if (providerError) return fail(`Der Anbieter meldet: ${providerError}`); + if (!code || !state) return fail("Unvollständige Antwort vom Anbieter."); + + const cookieStore = await cookies(); + const raw = cookieStore.get("flimmer_oidc")?.value; + cookieStore.delete({ name: "flimmer_oidc", path: "/api/auth/oidc" }); + if (!raw) return fail("Der Anmeldevorgang ist abgelaufen. Bitte noch einmal versuchen."); + + let stored: { state: string; verifier: string }; + try { + stored = JSON.parse(raw); + } catch { + return fail("Der Anmeldevorgang war ungültig."); + } + if (stored.state !== state) return fail("Der Anmeldevorgang war ungültig (state)."); + + let identity; + try { + identity = await exchangeCode(code, stored.verifier); + } catch (error) { + return fail(error instanceof Error ? error.message : "SSO-Anmeldung fehlgeschlagen."); + } + + // 1. Bereits verknüpft? + const link = await prisma.oidcAccount.findUnique({ + where: { issuer_subject: { issuer: identity.issuer, subject: identity.subject } }, + select: { user: { select: { id: true, isActive: true } } }, + }); + + if (link) { + if (!link.user.isActive) return fail("Dieses Konto ist deaktiviert."); + await createSession(link.user.id); + return NextResponse.redirect(new URL("/", env.appUrl)); + } + + // 2. Über die E-Mail-Adresse einem vorhandenen Konto zuordnen. + if (identity.email) { + const existing = await prisma.user.findUnique({ + where: { email: identity.email }, + select: { id: true, isActive: true }, + }); + if (existing) { + if (!existing.isActive) return fail("Dieses Konto ist deaktiviert."); + await prisma.oidcAccount.create({ + data: { userId: existing.id, issuer: identity.issuer, subject: identity.subject }, + }); + await createSession(existing.id); + return NextResponse.redirect(new URL("/", env.appUrl)); + } + } + + // 3. Neu anlegen — sofern der Betreiber das erlaubt. + if (!env.oidc.autoRegister) { + return fail("Zu dieser SSO-Kennung gibt es kein Konto. Bitte an die Administration wenden."); + } + if (!identity.email) { + return fail("Der Anbieter liefert keine E-Mail-Adresse — ein Konto lässt sich so nicht anlegen."); + } + + const isFirstUser = (await prisma.user.count()) === 0; + const user = await prisma.user.create({ + data: { + email: identity.email, + name: identity.name ?? identity.email.split("@")[0] ?? "Unbekannt", + role: isFirstUser ? "ADMIN" : "USER", + oidcAccounts: { + create: { issuer: identity.issuer, subject: identity.subject }, + }, + }, + select: { id: true }, + }); + + await createSession(user.id); + return NextResponse.redirect(new URL("/", env.appUrl)); +} diff --git a/src/app/api/auth/oidc/start/route.ts b/src/app/api/auth/oidc/start/route.ts new file mode 100644 index 0000000..3c86e49 --- /dev/null +++ b/src/app/api/auth/oidc/start/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import { env } from "@/lib/env"; +import { authorizationUrl, createChallenge } from "@/lib/oidc"; + +export const dynamic = "force-dynamic"; + +/** Startet den SSO-Anmeldevorgang. */ +export async function GET() { + if (!env.oidc.isConfigured) { + return NextResponse.redirect( + new URL("/anmelden?fehler=SSO ist auf dieser Instanz nicht eingerichtet.", env.appUrl), + ); + } + + try { + const { state, verifier, challenge } = createChallenge(); + const target = await authorizationUrl(state, challenge); + + // state und Verifier begleiten den Umweg über den Anbieter in einem + // kurzlebigen, nicht auslesbaren Cookie. + const cookieStore = await cookies(); + cookieStore.set("flimmer_oidc", JSON.stringify({ state, verifier }), { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/api/auth/oidc", + maxAge: 600, + }); + + return NextResponse.redirect(target); + } catch (error) { + const message = error instanceof Error ? error.message : "Unbekannter Fehler"; + return NextResponse.redirect( + new URL(`/anmelden?fehler=${encodeURIComponent(`SSO nicht erreichbar: ${message}`)}`, env.appUrl), + ); + } +} diff --git a/src/components/AuthForm.tsx b/src/components/AuthForm.tsx new file mode 100644 index 0000000..8e42035 --- /dev/null +++ b/src/components/AuthForm.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { useActionState } from "react"; +import { useFormStatus } from "react-dom"; +import Link from "next/link"; +import type { AuthFormState } from "@/app/(auth)/actions"; + +type Action = (state: AuthFormState, formData: FormData) => Promise; + +function SubmitButton({ label }: { label: string }) { + const { pending } = useFormStatus(); + return ( + + ); +} + +function Field({ + id, + label, + type = "text", + autoComplete, + required = true, + hint, +}: { + id: string; + label: string; + type?: string; + autoComplete?: string; + required?: boolean; + hint?: string; +}) { + return ( +
+ + + {hint && ( +

+ {hint} +

+ )} +
+ ); +} + +export function LoginForm({ action, weiter }: { action: Action; weiter?: string }) { + const [state, formAction] = useActionState(action, {} as AuthFormState); + + return ( +
+ {weiter && } + + + + + + ); +} + +export function RegisterForm({ action }: { action: Action }) { + const [state, formAction] = useActionState(action, {} as AuthFormState); + + return ( +
+ + + + + + + + ); +} + +export function FormError({ message }: { message?: string }) { + if (!message) return null; + return ( +

+ {message} +

+ ); +} + +export function AuthSwitch({ href, children }: { href: string; children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..0343a80 --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,224 @@ +import "server-only"; +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; +import { cookies, headers } from "next/headers"; +import { redirect } from "next/navigation"; +import bcrypt from "bcryptjs"; +import { prisma } from "./db"; +import type { Role, User } from "@/generated/prisma/client"; + +export const SESSION_COOKIE = "flimmer_session"; +const SESSION_DAYS = 30; +/** lastSeen nur stündlich fortschreiben — sonst schreibt jeder Seitenaufruf. */ +const LAST_SEEN_INTERVAL_MS = 60 * 60 * 1000; + +// --------------------------------------------------------------------------- +// Passwörter +// --------------------------------------------------------------------------- + +export async function hashPassword(plain: string): Promise { + return bcrypt.hash(plain, 12); +} + +export async function verifyPassword(plain: string, hash: string): Promise { + return bcrypt.compare(plain, hash); +} + +/** + * Mindestanforderung an Passwörter. Bewusst schlicht gehalten: Länge schlägt + * Zeichenklassen-Akrobatik. + */ +export function checkPasswordStrength(password: string): string | null { + if (password.length < 10) return "Das Passwort muss mindestens 10 Zeichen lang sein."; + if (password.length > 200) return "Das Passwort darf höchstens 200 Zeichen lang sein."; + if (/^\s|\s$/.test(password)) return "Das Passwort darf nicht mit einem Leerzeichen beginnen oder enden."; + return null; +} + +// --------------------------------------------------------------------------- +// Sitzungen +// --------------------------------------------------------------------------- + +function hashToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} + +/** + * Legt eine Sitzung an und setzt das Cookie. + * Im Klartext existiert das Token nur einmal — in der Datenbank liegt der Hash. + */ +export async function createSession(userId: string): Promise { + const token = randomBytes(32).toString("base64url"); + const expiresAt = new Date(Date.now() + SESSION_DAYS * 24 * 60 * 60 * 1000); + + const headerList = await headers(); + await prisma.session.create({ + data: { + userId, + tokenHash: hashToken(token), + expiresAt, + userAgent: headerList.get("user-agent")?.slice(0, 255) ?? null, + ip: + headerList.get("x-forwarded-for")?.split(",")[0]?.trim() ?? + headerList.get("x-real-ip") ?? + null, + }, + }); + + const cookieStore = await cookies(); + cookieStore.set(SESSION_COOKIE, token, { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/", + expires: expiresAt, + }); +} + +export type SessionUser = Pick< + User, + | "id" + | "email" + | "name" + | "role" + | "region" + | "locale" + | "publicProfile" + | "profileSlug" + | "isActive" +>; + +const SESSION_USER_FIELDS = { + id: true, + email: true, + name: true, + role: true, + region: true, + locale: true, + publicProfile: true, + profileSlug: true, + isActive: true, +} as const; + +/** Liefert den angemeldeten Nutzer oder null. Wirft nie. */ +export async function getSessionUser(): Promise { + const cookieStore = await cookies(); + const token = cookieStore.get(SESSION_COOKIE)?.value; + if (!token) return null; + + const session = await prisma.session.findUnique({ + where: { tokenHash: hashToken(token) }, + select: { + id: true, + expiresAt: true, + lastSeen: true, + user: { select: SESSION_USER_FIELDS }, + }, + }); + + 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) return null; + + if (Date.now() - session.lastSeen.getTime() > LAST_SEEN_INTERVAL_MS) { + await prisma.session + .update({ where: { id: session.id }, data: { lastSeen: new Date() } }) + .catch(() => undefined); + } + + return session.user; +} + +/** Für Seiten, die eine Anmeldung voraussetzen. */ +export async function requireUser(returnTo?: string): Promise { + const user = await getSessionUser(); + if (!user) { + const target = returnTo ? `?weiter=${encodeURIComponent(returnTo)}` : ""; + redirect(`/anmelden${target}`); + } + return user; +} + +export async function requireAdmin(): Promise { + const user = await requireUser(); + if (user.role !== ("ADMIN" satisfies Role)) { + redirect("/"); + } + return user; +} + +export async function destroySession(): Promise { + const cookieStore = await cookies(); + const token = cookieStore.get(SESSION_COOKIE)?.value; + if (token) { + await prisma.session + .deleteMany({ where: { tokenHash: hashToken(token) } }) + .catch(() => undefined); + } + cookieStore.delete(SESSION_COOKIE); +} + +/** Meldet alle Geräte eines Nutzers ab (z. B. nach Passwortwechsel). */ +export async function destroyAllSessions(userId: string): Promise { + await prisma.session.deleteMany({ where: { userId } }); +} + +// --------------------------------------------------------------------------- +// API-Schlüssel +// --------------------------------------------------------------------------- + +const API_KEY_PREFIX = "flm"; + +/** Erzeugt einen neuen API-Schlüssel. Der Klartext wird nur hier zurückgegeben. */ +export async function createApiKey(userId: string, name: string) { + const secret = randomBytes(24).toString("base64url"); + const prefix = `${API_KEY_PREFIX}_${randomBytes(4).toString("hex")}`; + const plain = `${prefix}.${secret}`; + + await prisma.apiKey.create({ + data: { userId, name, prefix, keyHash: hashToken(plain) }, + }); + + return { plain, prefix }; +} + +/** Prüft einen Bearer-Token aus dem Authorization-Header. */ +export async function authenticateApiKey(rawHeader: string | null): Promise { + const match = rawHeader?.match(/^Bearer\s+(.+)$/i); + if (!match?.[1]) return null; + + const plain = match[1].trim(); + const prefix = plain.split(".")[0]; + if (!prefix) return null; + + const key = await prisma.apiKey.findUnique({ + where: { prefix }, + select: { id: true, keyHash: true, revokedAt: true, user: { select: SESSION_USER_FIELDS } }, + }); + if (!key || key.revokedAt) return null; + + const given = Buffer.from(hashToken(plain)); + const stored = Buffer.from(key.keyHash); + if (given.length !== stored.length || !timingSafeEqual(given, stored)) return null; + if (!key.user.isActive) return null; + + await prisma.apiKey + .update({ where: { id: key.id }, data: { lastUsedAt: new Date() } }) + .catch(() => undefined); + + return key.user; +} + +/** + * Nutzer für einen API-Request: erst Bearer-Token, sonst Sitzungscookie. + * So funktionieren dieselben Endpunkte für Skripte und für das eigene Frontend. + */ +export async function getApiUser(request: Request): Promise { + const viaKey = await authenticateApiKey(request.headers.get("authorization")); + if (viaKey) return viaKey; + return getSessionUser(); +} diff --git a/src/lib/settings.ts b/src/lib/settings.ts new file mode 100644 index 0000000..59b42b1 --- /dev/null +++ b/src/lib/settings.ts @@ -0,0 +1,68 @@ +import "server-only"; +import { prisma } from "./db"; +import { env } from "./env"; + +/** + * Instanzweite Einstellungen. + * + * Was der Betreiber im laufenden Betrieb ändern können muss, liegt in der + * Datenbank (Tabelle AppSetting) — nicht in der .env, denn dafür müsste der + * Container neu gestartet werden. Die .env liefert nur die Erstbelegung. + */ + +export type AppSettings = { + /** Dürfen sich neue Nutzer selbst registrieren? */ + registrationEnabled: boolean; + /** Name der Instanz, erscheint im Header und in Benachrichtigungen. */ + instanceName: string; + /** Öffentliche Profilseiten instanzweit erlauben. */ + publicProfilesEnabled: boolean; +}; + +const DEFAULTS: AppSettings = { + registrationEnabled: env.allowRegistrationDefault, + instanceName: env.appName, + publicProfilesEnabled: true, +}; + +export async function getSettings(): Promise { + const rows = await prisma.appSetting.findMany(); + const stored = Object.fromEntries(rows.map((r) => [r.key, r.value])) as Partial; + + return { + registrationEnabled: + typeof stored.registrationEnabled === "boolean" + ? stored.registrationEnabled + : DEFAULTS.registrationEnabled, + instanceName: + typeof stored.instanceName === "string" && stored.instanceName.trim() + ? stored.instanceName + : DEFAULTS.instanceName, + publicProfilesEnabled: + typeof stored.publicProfilesEnabled === "boolean" + ? stored.publicProfilesEnabled + : DEFAULTS.publicProfilesEnabled, + }; +} + +export async function setSetting( + key: K, + value: AppSettings[K], +): Promise { + await prisma.appSetting.upsert({ + where: { key }, + create: { key, value: value as never }, + update: { value: value as never }, + }); +} + +/** + * Registrierung ist offen, solange es noch gar keinen Nutzer gibt — sonst + * käme man nach dem Aufsetzen nicht mehr an den ersten Admin-Zugang. + */ +export async function isRegistrationOpen(): Promise { + const userCount = await prisma.user.count(); + if (userCount === 0) return true; + const settings = await getSettings(); + return settings.registrationEnabled; +}