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:
@@ -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>;
|
||||
}
|
||||
|
||||
49
src/app/(auth)/login/actions.ts
Normal file
49
src/app/(auth)/login/actions.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
56
src/app/(auth)/login/login-form.tsx
Normal file
56
src/app/(auth)/login/login-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { handlers } from "@/auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
9
src/app/api/health/route.ts
Normal file
9
src/app/api/health/route.ts
Normal 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" });
|
||||
}
|
||||
50
src/auth.config.ts
Normal file
50
src/auth.config.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { NextAuthConfig } from "next-auth";
|
||||
import Authentik from "next-auth/providers/authentik";
|
||||
import { isHttps } from "@/lib/env";
|
||||
|
||||
/**
|
||||
* Edge-sichere Auth-Konfiguration. Dieses Modul wird auch in der Middleware
|
||||
* (Edge-Runtime) verwendet und darf daher WEDER `@/db` NOCH `@node-rs/argon2`
|
||||
* importieren (siehe Verifikation Workstream 3, Schritt 2).
|
||||
*
|
||||
* Cookie-`secure` und `__Secure-`-Präfix sind umgebungsabhängig (`isHttps`),
|
||||
* damit lokale HTTP-Entwicklung nicht bricht (Querschnittsstandard 9).
|
||||
*/
|
||||
export const authConfig = {
|
||||
trustHost: true,
|
||||
pages: { signIn: "/login", error: "/login" },
|
||||
session: { strategy: "jwt", maxAge: 60 * 60 * 8 },
|
||||
cookies: {
|
||||
sessionToken: {
|
||||
name: isHttps ? "__Secure-floriannetz.session" : "floriannetz.session",
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
secure: isHttps,
|
||||
},
|
||||
},
|
||||
},
|
||||
providers: [
|
||||
Authentik({
|
||||
issuer: process.env.AUTHENTIK_ISSUER!,
|
||||
clientId: process.env.AUTHENTIK_CLIENT_ID!,
|
||||
clientSecret: process.env.AUTHENTIK_CLIENT_SECRET!,
|
||||
}),
|
||||
],
|
||||
callbacks: {
|
||||
authorized: ({ auth }) => !!auth?.user,
|
||||
async jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.role = user.role;
|
||||
token.brigadeId = user.brigadeId ?? null;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
session.user.role = token.role;
|
||||
session.user.brigadeId = token.brigadeId ?? null;
|
||||
return session;
|
||||
},
|
||||
},
|
||||
} satisfies NextAuthConfig;
|
||||
68
src/auth.ts
Normal file
68
src/auth.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import NextAuth from "next-auth";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
import { z } from "zod";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { users } from "@/db/schema";
|
||||
import { authConfig } from "./auth.config";
|
||||
import { verifyPassword } from "@/lib/auth/password";
|
||||
import { checkRateLimit, recordAttempt } from "@/lib/auth/rate-limit";
|
||||
|
||||
const credSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1),
|
||||
});
|
||||
|
||||
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
...authConfig,
|
||||
providers: [
|
||||
...authConfig.providers,
|
||||
Credentials({
|
||||
credentials: { email: {}, password: {} },
|
||||
authorize: async (raw) => {
|
||||
const parsed = credSchema.safeParse(raw);
|
||||
if (!parsed.success) return null;
|
||||
const { email, password } = parsed.data;
|
||||
const key = `email:${email.toLowerCase()}`;
|
||||
// Rate-Limit greift für ALLE Credentials-Logins (default-deny).
|
||||
if (!(await checkRateLimit(key))) return null;
|
||||
const u = await db.query.users.findFirst({
|
||||
where: eq(users.email, email),
|
||||
});
|
||||
if (!u || !u.aktiv || u.authTyp !== "local" || !u.passwortHash) {
|
||||
await recordAttempt(key, "fail");
|
||||
return null;
|
||||
}
|
||||
if (!(await verifyPassword(u.passwortHash, password))) {
|
||||
await recordAttempt(key, "fail");
|
||||
return null;
|
||||
}
|
||||
await recordAttempt(key, "ok");
|
||||
return {
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
name: u.name,
|
||||
role: u.rolle,
|
||||
brigadeId: u.brigadeId,
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
callbacks: {
|
||||
...authConfig.callbacks,
|
||||
// Authentik-Login-Gate: nur vorgemerkte, aktive authentik-Konten zulassen.
|
||||
async signIn({ user, account }) {
|
||||
if (account?.provider === "authentik") {
|
||||
const email = user.email;
|
||||
if (!email) return false;
|
||||
const u = await db.query.users.findFirst({
|
||||
where: eq(users.email, email),
|
||||
});
|
||||
if (!u || !u.aktiv || u.authTyp !== "authentik") return false;
|
||||
user.role = u.rolle;
|
||||
user.brigadeId = u.brigadeId ?? null;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
27
src/lib/auth/__tests__/password.test.ts
Normal file
27
src/lib/auth/__tests__/password.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { hashPassword, verifyPassword, ARGON2_PARAMS } from "../password";
|
||||
|
||||
describe("password (argon2id)", () => {
|
||||
it("verwendet OWASP-Minima für argon2id", () => {
|
||||
// type 2 === argon2id
|
||||
expect(ARGON2_PARAMS.type).toBe(2);
|
||||
expect(ARGON2_PARAMS.memoryCost).toBeGreaterThanOrEqual(19456);
|
||||
expect(ARGON2_PARAMS.timeCost).toBeGreaterThanOrEqual(2);
|
||||
expect(ARGON2_PARAMS.parallelism).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("erzeugt einen argon2id-Hash mit korrektem Präfix", async () => {
|
||||
const h = await hashPassword("geheimes-passwort");
|
||||
expect(h.startsWith("$argon2id$")).toBe(true);
|
||||
});
|
||||
|
||||
it("verifiziert das korrekte Passwort", async () => {
|
||||
const h = await hashPassword("richtig");
|
||||
expect(await verifyPassword(h, "richtig")).toBe(true);
|
||||
});
|
||||
|
||||
it("lehnt ein falsches Passwort ab", async () => {
|
||||
const h = await hashPassword("richtig");
|
||||
expect(await verifyPassword(h, "falsch")).toBe(false);
|
||||
});
|
||||
});
|
||||
19
src/lib/auth/__tests__/rate-limit.test.ts
Normal file
19
src/lib/auth/__tests__/rate-limit.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { RATE_LIMIT, isWithinLimit } from "../rate-limit";
|
||||
|
||||
describe("rate-limit policy", () => {
|
||||
it("erlaubt max. 5 Fehlversuche pro Fenster", () => {
|
||||
expect(RATE_LIMIT.maxFails).toBe(5);
|
||||
expect(RATE_LIMIT.windowMs).toBe(15 * 60 * 1000);
|
||||
});
|
||||
|
||||
it("ist innerhalb des Limits bei < maxFails", () => {
|
||||
expect(isWithinLimit(0)).toBe(true);
|
||||
expect(isWithinLimit(4)).toBe(true);
|
||||
});
|
||||
|
||||
it("blockiert ab maxFails (6. Versuch)", () => {
|
||||
expect(isWithinLimit(5)).toBe(false);
|
||||
expect(isWithinLimit(6)).toBe(false);
|
||||
});
|
||||
});
|
||||
40
src/lib/auth/__tests__/roles.test.ts
Normal file
40
src/lib/auth/__tests__/roles.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { ROLES, ALL_ROLES, isRole, canAccessBrigade } from "../roles";
|
||||
|
||||
describe("roles", () => {
|
||||
it("definiert die drei Rollen als Single Source of Truth", () => {
|
||||
expect(ROLES).toEqual({
|
||||
PLATFORM_ADMIN: "platform_admin",
|
||||
WEHR_ADMIN: "wehr_admin",
|
||||
WEHR_READ: "wehr_read",
|
||||
});
|
||||
expect(ALL_ROLES).toEqual(["platform_admin", "wehr_admin", "wehr_read"]);
|
||||
});
|
||||
|
||||
it("isRole erkennt gültige Rollen", () => {
|
||||
expect(isRole("platform_admin")).toBe(true);
|
||||
expect(isRole("wehr_admin")).toBe(true);
|
||||
expect(isRole("wehr_read")).toBe(true);
|
||||
expect(isRole("root")).toBe(false);
|
||||
expect(isRole(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
describe("canAccessBrigade (Wehr-Scoping)", () => {
|
||||
it("platform_admin darf jede Wehr", () => {
|
||||
expect(
|
||||
canAccessBrigade("platform_admin", null, "wehr-b"),
|
||||
).toBe(true);
|
||||
});
|
||||
it("wehr_admin darf nur die eigene Wehr", () => {
|
||||
expect(canAccessBrigade("wehr_admin", "wehr-a", "wehr-a")).toBe(true);
|
||||
expect(canAccessBrigade("wehr_admin", "wehr-a", "wehr-b")).toBe(false);
|
||||
});
|
||||
it("wehr_read darf nur die eigene Wehr", () => {
|
||||
expect(canAccessBrigade("wehr_read", "wehr-a", "wehr-a")).toBe(true);
|
||||
expect(canAccessBrigade("wehr_read", "wehr-a", "wehr-b")).toBe(false);
|
||||
});
|
||||
it("ohne brigadeId verweigert (default-deny)", () => {
|
||||
expect(canAccessBrigade("wehr_admin", null, "wehr-a")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
102
src/lib/auth/guards.ts
Normal file
102
src/lib/auth/guards.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { redirect, forbidden } from "next/navigation";
|
||||
import { NextResponse } from "next/server";
|
||||
import type { Session } from "next-auth";
|
||||
import { auth } from "@/auth";
|
||||
import { canAccessBrigade, type Role } from "./roles";
|
||||
|
||||
/**
|
||||
* Vollständiger Guard-Satz (default-deny, Querschnittsstandard 1–3).
|
||||
*
|
||||
* - Server Components / Server Actions nutzen `requireSession`/`requireRole`/…
|
||||
* (sie werfen `redirect("/login")` bzw. `forbidden()` → 403).
|
||||
* - API-Routen nutzen `apiAuth` / `apiRequireRole` / `apiRequireOwnBrigade`,
|
||||
* die `NextResponse` mit 401/403 OHNE Fachdaten zurückgeben.
|
||||
*/
|
||||
export type AppSession = Session & {
|
||||
user: {
|
||||
id: string;
|
||||
role: Role;
|
||||
brigadeId: string | null;
|
||||
email: string;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
|
||||
// ── Server Components / Server Actions ─────────────────────────────────────
|
||||
|
||||
export async function requireSession(): Promise<AppSession> {
|
||||
const s = await auth();
|
||||
if (!s?.user) redirect("/login");
|
||||
return s as AppSession;
|
||||
}
|
||||
|
||||
export async function requireRole(...allowed: Role[]): Promise<AppSession> {
|
||||
const s = await requireSession();
|
||||
if (!allowed.includes(s.user.role)) forbidden(); // 403
|
||||
return s;
|
||||
}
|
||||
|
||||
export async function requirePlatformAdmin(): Promise<AppSession> {
|
||||
return requireRole("platform_admin");
|
||||
}
|
||||
|
||||
export async function requireWehrAdmin(): Promise<
|
||||
AppSession & { user: AppSession["user"] & { brigadeId: string } }
|
||||
> {
|
||||
const s = await requireRole("wehr_admin");
|
||||
if (!s.user.brigadeId) forbidden();
|
||||
return s as AppSession & { user: AppSession["user"] & { brigadeId: string } };
|
||||
}
|
||||
|
||||
export async function requireOwnBrigade(
|
||||
brigadeId: string,
|
||||
): Promise<AppSession> {
|
||||
const s = await requireRole("wehr_admin", "platform_admin");
|
||||
if (!canAccessBrigade(s.user.role, s.user.brigadeId, brigadeId)) forbidden();
|
||||
return s;
|
||||
}
|
||||
|
||||
// ── API-Routen (401/403 ohne Daten-Leak, Querschnittsstandard 2) ───────────
|
||||
|
||||
export type ApiAuthResult =
|
||||
| { ok: true; session: AppSession }
|
||||
| { ok: false; response: NextResponse };
|
||||
|
||||
const unauthorized = () =>
|
||||
NextResponse.json({ error: "Anmeldung erforderlich." }, { status: 401 });
|
||||
const forbiddenResponse = () =>
|
||||
NextResponse.json({ error: "Keine Berechtigung." }, { status: 403 });
|
||||
|
||||
export async function apiAuth(): Promise<ApiAuthResult> {
|
||||
const s = await auth();
|
||||
if (!s?.user) return { ok: false, response: unauthorized() };
|
||||
return { ok: true, session: s as AppSession };
|
||||
}
|
||||
|
||||
export async function apiRequireRole(
|
||||
...allowed: Role[]
|
||||
): Promise<ApiAuthResult> {
|
||||
const result = await apiAuth();
|
||||
if (!result.ok) return result;
|
||||
if (!allowed.includes(result.session.user.role)) {
|
||||
return { ok: false, response: forbiddenResponse() };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function apiRequireOwnBrigade(
|
||||
brigadeId: string,
|
||||
): Promise<ApiAuthResult> {
|
||||
const result = await apiRequireRole("wehr_admin", "platform_admin");
|
||||
if (!result.ok) return result;
|
||||
if (
|
||||
!canAccessBrigade(
|
||||
result.session.user.role,
|
||||
result.session.user.brigadeId,
|
||||
brigadeId,
|
||||
)
|
||||
) {
|
||||
return { ok: false, response: forbiddenResponse() };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
21
src/lib/auth/password.ts
Normal file
21
src/lib/auth/password.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { hash, verify } from "@node-rs/argon2";
|
||||
|
||||
/**
|
||||
* argon2id mit OWASP-Minima (Querschnittsstandard 11):
|
||||
* type=argon2id (2), memoryCost >= 19456 KiB, timeCost >= 2, parallelism >= 1.
|
||||
*
|
||||
* WICHTIG: Dieses Modul NIE im Edge-/Middleware-Pfad importieren — @node-rs/argon2
|
||||
* ist ein natives Node-Modul.
|
||||
*/
|
||||
export const ARGON2_PARAMS = {
|
||||
type: 2 as const, // 2 === argon2id
|
||||
memoryCost: 19456,
|
||||
timeCost: 2,
|
||||
parallelism: 1,
|
||||
};
|
||||
|
||||
export const hashPassword = (pw: string): Promise<string> =>
|
||||
hash(pw, ARGON2_PARAMS);
|
||||
|
||||
export const verifyPassword = (h: string, pw: string): Promise<boolean> =>
|
||||
verify(h, pw, ARGON2_PARAMS);
|
||||
47
src/lib/auth/rate-limit.ts
Normal file
47
src/lib/auth/rate-limit.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { and, eq, gte, sql } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { loginAttempts } from "@/db/schema";
|
||||
|
||||
/**
|
||||
* Login-Rate-Limit (Sliding Window) — greift im `authorize`-Callback und damit
|
||||
* für BEIDE Credentials-Login-Pfade. Liest/schreibt `login_attempts`
|
||||
* (Workstream-2-Schema, hier NICHT neu definiert).
|
||||
*
|
||||
* Querschnittsstandard 8 (Härtung): 5 Fehlversuche / 15 min pro `key`.
|
||||
*/
|
||||
export const RATE_LIMIT = {
|
||||
maxFails: 5,
|
||||
windowMs: 15 * 60 * 1000,
|
||||
} as const;
|
||||
|
||||
/** Reine Policy-Funktion (DB-frei, testbar): innerhalb des Limits? */
|
||||
export function isWithinLimit(failCount: number): boolean {
|
||||
return failCount < RATE_LIMIT.maxFails;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob für `key` im aktuellen Fenster noch Versuche erlaubt sind.
|
||||
* Gibt `true` zurück, wenn der Login fortgesetzt werden darf.
|
||||
*/
|
||||
export async function checkRateLimit(key: string): Promise<boolean> {
|
||||
const since = new Date(Date.now() - RATE_LIMIT.windowMs);
|
||||
const [row] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(loginAttempts)
|
||||
.where(
|
||||
and(
|
||||
eq(loginAttempts.key, key),
|
||||
eq(loginAttempts.erfolg, false),
|
||||
gte(loginAttempts.zeitpunkt, since),
|
||||
),
|
||||
);
|
||||
return isWithinLimit(row?.count ?? 0);
|
||||
}
|
||||
|
||||
/** Protokolliert einen Login-Versuch. */
|
||||
export async function recordAttempt(
|
||||
key: string,
|
||||
result: "ok" | "fail",
|
||||
): Promise<void> {
|
||||
await db.insert(loginAttempts).values({ key, erfolg: result === "ok" });
|
||||
}
|
||||
35
src/lib/auth/roles.ts
Normal file
35
src/lib/auth/roles.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { roleEnum } from "@/db/schema";
|
||||
|
||||
/**
|
||||
* Single Source of Truth für Rollen. Die zulässigen Werte stammen aus dem
|
||||
* DB-Enum `role` (Workstream 2) und werden hier NICHT neu definiert, sondern
|
||||
* abgeleitet, damit DB und Anwendung garantiert übereinstimmen.
|
||||
*/
|
||||
export const ALL_ROLES = roleEnum.enumValues;
|
||||
export type Role = (typeof ALL_ROLES)[number];
|
||||
|
||||
export const ROLES = {
|
||||
PLATFORM_ADMIN: "platform_admin",
|
||||
WEHR_ADMIN: "wehr_admin",
|
||||
WEHR_READ: "wehr_read",
|
||||
} as const satisfies Record<string, Role>;
|
||||
|
||||
export function isRole(value: unknown): value is Role {
|
||||
return (
|
||||
typeof value === "string" && (ALL_ROLES as readonly string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wehr-Scoping (default-deny): platform_admin darf wehrübergreifend; alle
|
||||
* anderen Rollen nur die eigene Wehr. Ohne eigene `brigadeId` wird verweigert.
|
||||
*/
|
||||
export function canAccessBrigade(
|
||||
role: Role,
|
||||
ownBrigadeId: string | null,
|
||||
targetBrigadeId: string,
|
||||
): boolean {
|
||||
if (role === ROLES.PLATFORM_ADMIN) return true;
|
||||
if (!ownBrigadeId) return false;
|
||||
return ownBrigadeId === targetBrigadeId;
|
||||
}
|
||||
@@ -11,6 +11,12 @@ export const de = {
|
||||
anmelden: "Anmelden",
|
||||
abmelden: "Abmelden",
|
||||
erforderlich: "Anmeldung erforderlich.",
|
||||
email: "E-Mail",
|
||||
passwort: "Passwort",
|
||||
mitAuthentik: "Mit Authentik anmelden",
|
||||
oderLokal: "oder mit lokalem Konto",
|
||||
fehlgeschlagen: "Anmeldung fehlgeschlagen. Bitte Eingaben prüfen.",
|
||||
zuVieleVersuche: "Zu viele Fehlversuche. Bitte später erneut versuchen.",
|
||||
},
|
||||
status: {
|
||||
einsatzbereit: "einsatzbereit",
|
||||
|
||||
15
src/middleware.ts
Normal file
15
src/middleware.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import NextAuth from "next-auth";
|
||||
import { authConfig } from "./auth.config";
|
||||
|
||||
// Edge-sichere Middleware (KEIN DB-/argon2-Import). Default-deny-Schicht 1:
|
||||
// nicht authentifizierte Aufrufe werden über `authorized` auf /login geleitet.
|
||||
export const { auth: middleware } = NextAuth(authConfig);
|
||||
|
||||
export default middleware(() => {});
|
||||
|
||||
export const config = {
|
||||
// Allowlist: NextAuth-Endpunkte, Health-Check, Login-Seite, Next-Statics.
|
||||
matcher: [
|
||||
"/((?!api/auth|api/health|login|_next/static|_next/image|favicon.ico|.*\\.(?:png|svg|ico|jpg|webp|woff2?)$).*)",
|
||||
],
|
||||
};
|
||||
31
src/types/next-auth.d.ts
vendored
Normal file
31
src/types/next-auth.d.ts
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
import { type Role } from "@/lib/auth/roles";
|
||||
import type { DefaultSession } from "next-auth";
|
||||
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
user: {
|
||||
id: string;
|
||||
role: Role;
|
||||
brigadeId: string | null;
|
||||
} & DefaultSession["user"];
|
||||
}
|
||||
|
||||
interface User {
|
||||
role: Role;
|
||||
brigadeId: string | null;
|
||||
}
|
||||
}
|
||||
|
||||
declare module "next-auth/jwt" {
|
||||
interface JWT {
|
||||
role: Role;
|
||||
brigadeId: string | null;
|
||||
}
|
||||
}
|
||||
|
||||
declare module "@auth/core/jwt" {
|
||||
interface JWT {
|
||||
role: Role;
|
||||
brigadeId: string | null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user