Files
Florian-netz/src/auth.ts
Matthias Hochmeister ae5d3589c3 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>
2026-06-09 09:17:02 +02:00

69 lines
2.2 KiB
TypeScript

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;
},
},
});