feat: Registrierung, Anmeldung und Sitzungen

Passwörter mit bcrypt, Sitzungen als opake Token in der Datenbank — im
Cookie steht das Klartext-Token, in der Tabelle nur dessen SHA-256. Damit
lassen sich einzelne Geräte gezielt abmelden, was reine JWT-Sitzungen nicht
hergeben.

Der erste registrierte Zugang wird automatisch Administrator, sonst käme
man nach dem Aufsetzen nicht in die Verwaltung. Die Registrierung ist
instanzweit abschaltbar; solange es keinen Nutzer gibt, bleibt sie offen.

Enthält außerdem API-Schlüssel je Nutzer als Grundlage für die spätere
REST-API und die Instanz-Einstellungen in der Tabelle AppSetting.
This commit is contained in:
Florian Wilde
2026-09-01 18:46:27 +02:00
parent 3e11e4ffb1
commit 01c0f9bc9f
10 changed files with 812 additions and 0 deletions
+118
View File
@@ -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<AuthFormState> {
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<AuthFormState> {
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("/");
}
+61
View File
@@ -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 (
<div className="fl-card p-6 sm:p-8">
<h1 className="text-2xl font-semibold tracking-tight">Willkommen zurück</h1>
<p className="mt-1 mb-6 text-sm text-[var(--fl-muted)]">
Melde dich an, um weiterzuschauen.
</p>
{params.fehler && (
<p
role="alert"
className="mb-4 rounded-xl border border-[color-mix(in_oklab,var(--fl-danger)_45%,transparent)] bg-[color-mix(in_oklab,var(--fl-danger)_12%,transparent)] px-3 py-2 text-sm text-[var(--fl-danger)]"
>
{params.fehler}
</p>
)}
<LoginForm action={loginAction} weiter={params.weiter} />
{env.oidc.isConfigured && (
<>
<div className="my-5 flex items-center gap-3 text-xs text-[var(--fl-faint)]">
<span className="h-px flex-1 bg-[var(--fl-border)]" />
oder
<span className="h-px flex-1 bg-[var(--fl-border)]" />
</div>
<a href="/api/auth/oidc/start" className="fl-btn fl-btn-ghost w-full">
{env.oidc.buttonLabel}
</a>
</>
)}
{registrationOpen && (
<div className="mt-6">
<AuthSwitch href="/registrieren">Noch kein Konto? Jetzt registrieren</AuthSwitch>
</div>
)}
<p className="mt-6 text-center text-xs text-[var(--fl-faint)]">{settings.instanceName}</p>
</div>
);
}
+29
View File
@@ -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 (
<div className="flex min-h-dvh flex-col">
<div className="flex items-center justify-between px-4 py-4 sm:px-6">
<Link href="/" aria-label="Flimmer">
<FlimmerLogo />
</Link>
<ThemeToggle />
</div>
<main id="inhalt" className="flex flex-1 items-center justify-center px-4 py-8">
<div className="w-full max-w-md">{children}</div>
</main>
<footer className="px-4 pb-8 text-center text-xs text-[var(--fl-faint)]">
Flimmer {pkg.version} · selbst gehostet
</footer>
</div>
);
}
+48
View File
@@ -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 (
<div className="fl-card p-6 sm:p-8">
<h1 className="text-2xl font-semibold tracking-tight">Registrierung geschlossen</h1>
<p className="mt-2 text-sm text-[var(--fl-muted)]">
Auf dieser Instanz können sich keine neuen Konten anlegen. Wende dich an die
Administration, wenn du einen Zugang brauchst.
</p>
<div className="mt-6">
<AuthSwitch href="/anmelden">Zur Anmeldung</AuthSwitch>
</div>
</div>
);
}
return (
<div className="fl-card p-6 sm:p-8">
<h1 className="text-2xl font-semibold tracking-tight">Konto anlegen</h1>
<p className="mt-1 mb-6 text-sm text-[var(--fl-muted)]">
{isFirstUser
? "Du bist die erste Person hier — dieses Konto wird automatisch Administrator."
: "Lege dir deine eigene Bibliothek an."}
</p>
<RegisterForm action={registerAction} />
<div className="mt-6">
<AuthSwitch href="/anmelden">Schon ein Konto? Zur Anmeldung</AuthSwitch>
</div>
</div>
);
}
+9
View File
@@ -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 });
}
+99
View File
@@ -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));
}
+38
View File
@@ -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),
);
}
}