Workstream 3: Authentifizierung & Zugriffskontrolle (Phase 2)

Einheitliche Auth.js-v5-Sitzung (role + brigadeId) über Authentik-OIDC
(Platform-Admins) und Credentials/argon2id (Wehr-Konten). Default-deny
dreifach: Middleware + Layout-Guard im (app)-Segment + 401/403 in API-Routen
und Guards in Server Actions.

- src/lib/auth/roles.ts: Rollen als Single Source of Truth (aus DB-Enum
  abgeleitet) + canAccessBrigade-Wehr-Scoping.
- src/lib/auth/password.ts: argon2id mit OWASP-Minima (Node-only).
- src/lib/auth/rate-limit.ts: Sliding Window (5 Fehlversuche/15 min) auf
  login_attempts; greift im authorize-Callback (beide Login-Pfade).
- src/auth.config.ts: Edge-sicher (kein @/db, kein argon2), Cookie-secure/
  __Secure- umgebungsabhaengig (isHttps).
- src/auth.ts: Credentials + DB-Lookup + Rate-Limit + Authentik-signIn-Gate.
- src/middleware.ts: Allowlist inkl. api/auth, api/health, login, Statics.
- src/lib/auth/guards.ts: requireSession/requireRole/requirePlatformAdmin/
  requireWehrAdmin/requireOwnBrigade + API-Varianten (401/403 ohne Daten-Leak).
- (app)/layout.tsx: requireSession() als erste Zeile aktiviert.
- (auth)/login: page + login-form + Server Actions (Zod, Authentik + lokal).
- api/auth/[...nextauth]/route.ts; api/health/route.ts (anonym 200).
- scripts/seed-auth.ts: idempotenter Erst-Admin-Seed.
- Typen: src/types/next-auth.d.ts.
- Tests: Unit (password/roles/rate-limit) gruen; E2E-Gating-Spec geschrieben
  (deferred, kein Server/DB in Sandbox).

Offline verifiziert: tsc --noEmit, next lint, next build, drizzle-kit check,
vitest (13 Unit-Tests) je ohne Fehler; Edge-Safety-Grep ohne DB/argon2-Import.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthias Hochmeister
2026-06-09 09:17:02 +02:00
parent a9666ff96c
commit ae5d3589c3
25 changed files with 1303 additions and 37 deletions

View File

@@ -1,24 +1,20 @@
import { AppShell } from "@/components/layout/app-shell";
import { requireSession } from "@/lib/auth/guards";
// Gated App-Shell.
//
// GUARD-SLOT (Default-deny, dreifach — Querschnittsstandard 1):
// Der Auth-Workstream (Workstream 3) fügt hier als ALLERERSTE Anweisung den
// serverseitigen Session-Guard ein, z. B.:
//
// import { requireSession } from "@/lib/auth/guards";
// ...
// await requireSession(); // leitet anonyme Aufrufe auf /login um
// Der serverseitige Session-Guard ist hier die ALLERERSTE Anweisung. Er leitet
// anonyme Aufrufe der GANZEN (app)-Gruppe auf /login um (Verteidigung in der
// Tiefe — Lese-Seiten verlassen sich NICHT allein auf die Middleware).
//
// Kanonischer Login-Pfad: /login (siehe (auth)/login/page.tsx). Dieselbe Route
// nutzen NextAuth pages.signIn, PUBLIC_ALLOWLIST und die Gating-Specs.
//
// Lese-Seiten dürfen sich NICHT allein auf die Middleware verlassen.
// nutzen NextAuth pages.signIn, die Middleware-Allowlist und die Gating-Specs.
export default async function AppLayout({
children,
}: {
children: React.ReactNode;
}) {
// await requireSession(); // <- vom Auth-Workstream zu aktivieren
await requireSession(); // Default-deny: leitet anonyme Aufrufe auf /login um
return <AppShell>{children}</AppShell>;
}

View File

@@ -0,0 +1,49 @@
"use server";
import { AuthError } from "next-auth";
import { z } from "zod";
import { signIn } from "@/auth";
import { t } from "@/lib/i18n/de";
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
export type LoginState = { error: string } | null;
/**
* Server Action für den Authentik-OIDC-Login. Leitet zum Provider weiter; das
* `signIn`-Gate in `auth.ts` lässt nur vorgemerkte, aktive authentik-Konten zu.
*/
export async function authentikLoginAction(): Promise<void> {
await signIn("authentik", { redirectTo: "/" });
}
export async function loginAction(
_prev: LoginState,
formData: FormData,
): Promise<LoginState> {
const parsed = loginSchema.safeParse({
email: formData.get("email"),
password: formData.get("password"),
});
if (!parsed.success) {
return { error: t("auth.fehlgeschlagen") };
}
try {
await signIn("credentials", {
email: parsed.data.email,
password: parsed.data.password,
redirectTo: "/",
});
return null;
} catch (error) {
if (error instanceof AuthError) {
return { error: t("auth.fehlgeschlagen") };
}
// NEXT_REDIRECT u. ä. müssen weitergereicht werden.
throw error;
}
}

View File

@@ -0,0 +1,56 @@
"use client";
import { useActionState } from "react";
import { loginAction, type LoginState } from "./actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { t } from "@/lib/i18n/de";
export function LoginForm() {
const [state, formAction, pending] = useActionState<LoginState, FormData>(
loginAction,
null,
);
return (
<form action={formAction} className="mt-6 space-y-4">
{state?.error ? (
<p
role="alert"
className="rounded-sm border border-signal/30 bg-signal/5 px-3 py-2 text-sm text-signal"
>
{state.error}
</p>
) : null}
<div className="space-y-1.5">
<Label htmlFor="email">{t("auth.email")}</Label>
<Input
id="email"
name="email"
type="email"
autoComplete="username"
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="password">{t("auth.passwort")}</Label>
<Input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
/>
</div>
<Button
type="submit"
variant="outline"
className="w-full"
disabled={pending}
>
{t("auth.anmelden")}
</Button>
</form>
);
}

View File

@@ -1,33 +1,33 @@
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { authentikLoginAction } from "./actions";
import { LoginForm } from "./login-form";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { t } from "@/lib/i18n/de";
// Platzhalter-Anmeldeseite. Der Auth-Workstream (Workstream 3) ersetzt die
// Logik durch Authentik-OIDC bzw. lokalen Login.
export default function AnmeldenPage() {
// Anmeldeseite (öffentlich, in der Middleware-Allowlist). Bietet Authentik-OIDC
// (Platform-Admins) und lokalen Credentials-Login (Wehr-Konten).
export default async function AnmeldenPage() {
// Bereits angemeldete Nutzer nicht erneut anmelden lassen.
const session = await auth();
if (session?.user) redirect("/");
return (
<div>
<h1 className="font-display text-xl text-navy">{t("auth.anmelden")}</h1>
<p className="mt-1 text-sm text-anthrazit/70">{t("auth.erforderlich")}</p>
<form className="mt-6 space-y-4">
<div className="space-y-1.5">
<Label htmlFor="email">E-Mail</Label>
<Input id="email" name="email" type="email" autoComplete="username" />
</div>
<div className="space-y-1.5">
<Label htmlFor="passwort">Passwort</Label>
<Input
id="passwort"
name="passwort"
type="password"
autoComplete="current-password"
/>
</div>
<Button type="submit" className="w-full" disabled>
{t("auth.anmelden")}
<form action={authentikLoginAction} className="mt-6">
<Button type="submit" variant="primary" className="w-full">
{t("auth.mitAuthentik")}
</Button>
</form>
<p className="mt-4 text-center text-xs text-anthrazit/60">
{t("auth.oderLokal")}
</p>
<LoginForm />
</div>
);
}

View File

@@ -0,0 +1,3 @@
import { handlers } from "@/auth";
export const { GET, POST } = handlers;

View File

@@ -0,0 +1,9 @@
import { NextResponse } from "next/server";
// Öffentlicher Health-Check (anonym 200). In der Middleware-Allowlist; dient
// Container-/Reverse-Proxy-Health-Probes. Enthält bewusst keine Fachdaten.
export const dynamic = "force-dynamic";
export function GET() {
return NextResponse.json({ status: "ok" });
}