diff --git a/.env.example b/.env.example index d6c4524..8e338bf 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,10 @@ AUTH_TRUST_HOST=true AUTHENTIK_ISSUER=http://localhost:9000/application/o/floriannetz/ AUTHENTIK_CLIENT_ID=floriannetz AUTHENTIK_CLIENT_SECRET=bitte-setzen +# Mitglieder dieser Authentik-Gruppe erhalten beim Login automatisch +# platform_admin. Wer NICHT in der Gruppe ist, wird vom SSO-Login abgewiesen. +# Setup siehe docs/reference/authentik-setup.md. +AUTHENTIK_ADMIN_GROUP=floriannetz-admins # Geo (interne Dienste; Defaults zeigen auf Docker-Compose-Hostnamen) OSRM_URL=http://osrm:5000 diff --git a/docs/reference/authentik-setup.md b/docs/reference/authentik-setup.md new file mode 100644 index 0000000..4b7656e --- /dev/null +++ b/docs/reference/authentik-setup.md @@ -0,0 +1,53 @@ +# Authentik-Integration & Admin-Zugang (FlorianNetz) + +FlorianNetz nutzt Authentik als **OIDC-Identitätsanbieter**. Der **Admin-Zugang +(platform_admin) wird zentral über eine Authentik-Gruppe** gesteuert — nicht über +manuell gesetzte DB-Rollen. + +## Wie es funktioniert + +- Anmeldung über Authentik (OIDC). FlorianNetz fordert die Scopes + `openid email profile groups` an. +- Im `signIn`-Callback (`src/auth.ts`) wird der `groups`-Claim ausgewertet: + - **Mitglied der Admin-Gruppe** (`AUTHENTIK_ADMIN_GROUP`, Standard + `floriannetz-admins`) → wird (idempotent) als `platform_admin` in `users` + angelegt/aktualisiert und eingeloggt. + - **Kein Mitglied** → Login wird **abgewiesen** (`return false`). +- Folge: Admins werden **in Authentik** verwaltet (Gruppenmitgliedschaft), nicht + per `seed-auth`. Ein erstes manuelles Seeding entfällt (kein Henne-Ei-Problem). +- **Wehr-Konten** (wehr_admin/wehr_read) bleiben **lokale** App-Konten + (E-Mail+Passwort), die Wehr-Admins selbst anlegen — sie nutzen NICHT Authentik. + +## Einrichtung in Authentik + +1. **Gruppe anlegen:** z. B. `floriannetz-admins`; gewünschte Admin-Benutzer + hinzufügen. (Muss exakt `AUTHENTIK_ADMIN_GROUP` entsprechen.) +2. **Provider anlegen:** OAuth2/OpenID Provider + - Redirect-URI: `https:///api/auth/callback/authentik` + - Signing Key wie üblich; Client-Typ „Confidential". +3. **Scopes/Property-Mappings:** dem Provider die Scope-Mappings + `openid`, `email`, `profile` **und** das Gruppen-Mapping zuweisen, das den + `groups`-Claim liefert (Authentik-Standard: „authentik default OAuth Mapping: + OpenID 'groups'"). Ohne dieses Mapping enthält das Token keine `groups` und + **niemand** erhält Admin-Zugang. +4. **Application anlegen** und mit dem Provider verknüpfen; Slug muss zum + `AUTHENTIK_ISSUER` passen (`…/application/o//`). +5. **Client-ID/-Secret** aus dem Provider übernehmen. + +## Umgebungsvariablen + +``` +AUTHENTIK_ISSUER=https://auth.example.at/application/o/floriannetz/ +AUTHENTIK_CLIENT_ID=… +AUTHENTIK_CLIENT_SECRET=… +AUTHENTIK_ADMIN_GROUP=floriannetz-admins +``` + +## Prüfen + +- Mitglied von `floriannetz-admins` meldet sich an → landet als Admin in + `/admin`; in `users` existiert eine Zeile `authTyp='authentik'`, + `rolle='platform_admin'`. +- Nicht-Mitglied meldet sich an → Login abgewiesen (zurück zu `/login`). +- `groups`-Claim fehlt (Mapping nicht zugewiesen) → alle SSO-Logins abgewiesen + (erwartetes Fail-safe-Verhalten: kein Claim ⇒ kein Admin). diff --git a/src/auth.config.ts b/src/auth.config.ts index 10e2e59..cb0fe14 100644 --- a/src/auth.config.ts +++ b/src/auth.config.ts @@ -30,6 +30,9 @@ export const authConfig = { issuer: process.env.AUTHENTIK_ISSUER!, clientId: process.env.AUTHENTIK_CLIENT_ID!, clientSecret: process.env.AUTHENTIK_CLIENT_SECRET!, + // `groups`-Claim anfordern, damit der Admin-Zugang über die + // Authentik-Gruppenmitgliedschaft gesteuert werden kann (signIn-Callback). + authorization: { params: { scope: "openid email profile groups" } }, }), ], callbacks: { diff --git a/src/auth.ts b/src/auth.ts index 31b7e0a..3e01138 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -7,12 +7,47 @@ import { users } from "@/db/schema"; import { authConfig } from "./auth.config"; import { verifyPassword } from "@/lib/auth/password"; import { checkRateLimit, recordAttempt } from "@/lib/auth/rate-limit"; +import { extractGroups, isAdminGroupMember } from "@/lib/auth/authentik"; +import { ROLES } from "@/lib/auth/roles"; +import { env } from "@/lib/env"; const credSchema = z.object({ email: z.string().email(), password: z.string().min(1), }); +/** + * Stellt sicher, dass ein Authentik-Admin (Mitglied der Admin-Gruppe) als + * platform_admin in `users` existiert — für eine stabile id (Audit/FKs). + * Idempotent über die eindeutige E-Mail; benötigt KEIN vorheriges Seeding. + */ +async function upsertAuthentikAdmin(email: string, name: string | null) { + const normalized = email.toLowerCase(); + const rows = await db + .insert(users) + .values({ + email: normalized, + name: name ?? normalized, + rolle: ROLES.PLATFORM_ADMIN, + authTyp: "authentik", + aktiv: true, + brigadeId: null, + }) + .onConflictDoUpdate({ + target: users.email, + set: { + rolle: ROLES.PLATFORM_ADMIN, + authTyp: "authentik", + aktiv: true, + ...(name ? { name } : {}), + }, + }) + .returning(); + const row = rows[0]; + if (!row) throw new Error("Authentik-Admin-Upsert lieferte keine Zeile"); + return row; +} + export const { handlers, auth, signIn, signOut } = NextAuth({ ...authConfig, providers: [ @@ -50,17 +85,19 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ ], callbacks: { ...authConfig.callbacks, - // Authentik-Login-Gate: nur vorgemerkte, aktive authentik-Konten zulassen. - async signIn({ user, account }) { + // Authentik-Login = Admin-Zugang, gesteuert über die Authentik-GRUPPE: + // Nur Mitglieder von AUTHENTIK_ADMIN_GROUP dürfen rein und werden + // (idempotent) als platform_admin angelegt. Alle anderen werden abgewiesen. + async signIn({ user, account, profile }) { 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; + const groups = extractGroups(profile); + if (!isAdminGroupMember(groups, env.AUTHENTIK_ADMIN_GROUP)) return false; + const u = await upsertAuthentikAdmin(email, user.name ?? null); + user.id = u.id; user.role = u.rolle; - user.brigadeId = u.brigadeId ?? null; + user.brigadeId = null; } return true; }, diff --git a/src/lib/auth/__tests__/authentik.test.ts b/src/lib/auth/__tests__/authentik.test.ts new file mode 100644 index 0000000..02487e6 --- /dev/null +++ b/src/lib/auth/__tests__/authentik.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from "vitest"; +import { extractGroups, isAdminGroupMember } from "@/lib/auth/authentik"; + +describe("isAdminGroupMember", () => { + it("true, wenn die Admin-Gruppe enthalten ist", () => { + expect( + isAdminGroupMember(["wehr-x", "floriannetz-admins"], "floriannetz-admins"), + ).toBe(true); + }); + + it("false, wenn die Admin-Gruppe fehlt", () => { + expect(isAdminGroupMember(["wehr-x"], "floriannetz-admins")).toBe(false); + }); + + it("false bei leerer Gruppenliste", () => { + expect(isAdminGroupMember([], "floriannetz-admins")).toBe(false); + }); +}); + +describe("extractGroups", () => { + it("liest den groups-Claim", () => { + expect(extractGroups({ sub: "1", groups: ["a", "b"] })).toEqual(["a", "b"]); + }); + + it("leeres Array, wenn kein groups-Claim vorhanden ist", () => { + expect(extractGroups({ sub: "1" })).toEqual([]); + }); + + it("leeres Array bei undefined/null", () => { + expect(extractGroups(undefined)).toEqual([]); + expect(extractGroups(null)).toEqual([]); + }); + + it("defensiv: ignoriert nicht-string-Arrays", () => { + expect(extractGroups({ groups: "kein-array" })).toEqual([]); + expect(extractGroups({ groups: [1, 2, 3] })).toEqual([]); + }); +}); diff --git a/src/lib/auth/authentik.ts b/src/lib/auth/authentik.ts new file mode 100644 index 0000000..1d30af7 --- /dev/null +++ b/src/lib/auth/authentik.ts @@ -0,0 +1,25 @@ +import { z } from "zod"; + +/** + * Reine Helfer für die Authentik-Gruppensteuerung des Admin-Zugangs. + * Bewusst OHNE DB-/Node-Importe, damit sie isoliert unit-testbar sind und + * auch im Edge-Pfad unbedenklich wären. + */ + +const profileWithGroups = z + .object({ groups: z.array(z.string()).optional() }) + .passthrough(); + +/** Extrahiert den `groups`-Claim aus dem Authentik-OIDC-Profil (defensiv). */ +export function extractGroups(profile: unknown): string[] { + const parsed = profileWithGroups.safeParse(profile); + return parsed.success && parsed.data.groups ? parsed.data.groups : []; +} + +/** Entscheidung: Ist der Benutzer Mitglied der konfigurierten Admin-Gruppe? */ +export function isAdminGroupMember( + groups: readonly string[], + adminGroup: string, +): boolean { + return groups.includes(adminGroup); +} diff --git a/src/lib/env.ts b/src/lib/env.ts index d78f1b1..1907235 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -10,6 +10,8 @@ const serverSchema = z.object({ AUTHENTIK_ISSUER: z.string().url(), AUTHENTIK_CLIENT_ID: z.string().min(1), AUTHENTIK_CLIENT_SECRET: z.string().min(1), + // Authentik-Gruppe, deren Mitglieder automatisch platform_admin werden. + AUTHENTIK_ADMIN_GROUP: z.string().min(1).default("floriannetz-admins"), // Geo: OSRM_URL: z.string().url().default("http://osrm:5000"), NOMINATIM_URL: z.string().url().default("http://nominatim:8080"),