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:
@@ -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("/");
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<AuthFormState>;
|
||||||
|
|
||||||
|
function SubmitButton({ label }: { label: string }) {
|
||||||
|
const { pending } = useFormStatus();
|
||||||
|
return (
|
||||||
|
<button type="submit" className="fl-btn fl-btn-primary w-full" disabled={pending}>
|
||||||
|
{pending ? "Einen Moment …" : label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
type = "text",
|
||||||
|
autoComplete,
|
||||||
|
required = true,
|
||||||
|
hint,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
type?: string;
|
||||||
|
autoComplete?: string;
|
||||||
|
required?: boolean;
|
||||||
|
hint?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label htmlFor={id} className="block text-sm font-medium text-[var(--fl-muted)]">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
name={id}
|
||||||
|
type={type}
|
||||||
|
autoComplete={autoComplete}
|
||||||
|
required={required}
|
||||||
|
className="fl-input"
|
||||||
|
aria-describedby={hint ? `${id}-hint` : undefined}
|
||||||
|
/>
|
||||||
|
{hint && (
|
||||||
|
<p id={`${id}-hint`} className="text-xs text-[var(--fl-faint)]">
|
||||||
|
{hint}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoginForm({ action, weiter }: { action: Action; weiter?: string }) {
|
||||||
|
const [state, formAction] = useActionState(action, {} as AuthFormState);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={formAction} className="space-y-4">
|
||||||
|
{weiter && <input type="hidden" name="weiter" value={weiter} />}
|
||||||
|
<Field id="email" label="E-Mail-Adresse" type="email" autoComplete="email" />
|
||||||
|
<Field id="password" label="Passwort" type="password" autoComplete="current-password" />
|
||||||
|
<FormError message={state.error} />
|
||||||
|
<SubmitButton label="Anmelden" />
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RegisterForm({ action }: { action: Action }) {
|
||||||
|
const [state, formAction] = useActionState(action, {} as AuthFormState);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={formAction} className="space-y-4">
|
||||||
|
<Field id="name" label="Anzeigename" autoComplete="name" />
|
||||||
|
<Field id="email" label="E-Mail-Adresse" type="email" autoComplete="email" />
|
||||||
|
<Field
|
||||||
|
id="password"
|
||||||
|
label="Passwort"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
hint="Mindestens 10 Zeichen. Länge hilft mehr als Sonderzeichen."
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
id="passwordRepeat"
|
||||||
|
label="Passwort wiederholen"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
<FormError message={state.error} />
|
||||||
|
<SubmitButton label="Konto anlegen" />
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormError({ message }: { message?: string }) {
|
||||||
|
if (!message) return null;
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
className="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)]"
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthSwitch({ href, children }: { href: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className="block text-center text-sm text-[var(--fl-muted)] underline-offset-4 hover:text-[var(--fl-text)] hover:underline"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
+224
@@ -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<string> {
|
||||||
|
return bcrypt.hash(plain, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPassword(plain: string, hash: string): Promise<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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<SessionUser | null> {
|
||||||
|
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<SessionUser> {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) {
|
||||||
|
const target = returnTo ? `?weiter=${encodeURIComponent(returnTo)}` : "";
|
||||||
|
redirect(`/anmelden${target}`);
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireAdmin(): Promise<SessionUser> {
|
||||||
|
const user = await requireUser();
|
||||||
|
if (user.role !== ("ADMIN" satisfies Role)) {
|
||||||
|
redirect("/");
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function destroySession(): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<SessionUser | null> {
|
||||||
|
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<SessionUser | null> {
|
||||||
|
const viaKey = await authenticateApiKey(request.headers.get("authorization"));
|
||||||
|
if (viaKey) return viaKey;
|
||||||
|
return getSessionUser();
|
||||||
|
}
|
||||||
@@ -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<AppSettings> {
|
||||||
|
const rows = await prisma.appSetting.findMany();
|
||||||
|
const stored = Object.fromEntries(rows.map((r) => [r.key, r.value])) as Partial<AppSettings>;
|
||||||
|
|
||||||
|
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<K extends keyof AppSettings>(
|
||||||
|
key: K,
|
||||||
|
value: AppSettings[K],
|
||||||
|
): Promise<void> {
|
||||||
|
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<boolean> {
|
||||||
|
const userCount = await prisma.user.count();
|
||||||
|
if (userCount === 0) return true;
|
||||||
|
const settings = await getSettings();
|
||||||
|
return settings.registrationEnabled;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user