feat(auth): Admin-Zugang über Authentik-Gruppe steuern
Statt manuell gesetzter DB-Rolle erhalten Mitglieder der Authentik-Gruppe AUTHENTIK_ADMIN_GROUP (Default floriannetz-admins) beim SSO-Login automatisch platform_admin; Nicht-Mitglieder werden abgewiesen. Erstes Seeding entfällt. - auth.config.ts: Scope 'openid email profile groups' anfordern - lib/auth/authentik.ts: reine Helfer extractGroups/isAdminGroupMember (+ 7 Unit-Tests) - auth.ts: signIn wertet groups-Claim aus, upsert (idempotent) als platform_admin mit stabiler users.id für Audit/FKs - env.ts/.env.example: AUTHENTIK_ADMIN_GROUP - docs/reference/authentik-setup.md: Provider-/Gruppen-/Scope-Setup Verifiziert offline: tsc OK; lint sauber; vitest 240 passed / 7 skipped. Wehr-Konten bleiben lokale Accounts (kein Authentik). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,10 @@ AUTH_TRUST_HOST=true
|
|||||||
AUTHENTIK_ISSUER=http://localhost:9000/application/o/floriannetz/
|
AUTHENTIK_ISSUER=http://localhost:9000/application/o/floriannetz/
|
||||||
AUTHENTIK_CLIENT_ID=floriannetz
|
AUTHENTIK_CLIENT_ID=floriannetz
|
||||||
AUTHENTIK_CLIENT_SECRET=bitte-setzen
|
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)
|
# Geo (interne Dienste; Defaults zeigen auf Docker-Compose-Hostnamen)
|
||||||
OSRM_URL=http://osrm:5000
|
OSRM_URL=http://osrm:5000
|
||||||
|
|||||||
53
docs/reference/authentik-setup.md
Normal file
53
docs/reference/authentik-setup.md
Normal file
@@ -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://<APP_HOST>/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/<slug>/`).
|
||||||
|
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).
|
||||||
@@ -30,6 +30,9 @@ export const authConfig = {
|
|||||||
issuer: process.env.AUTHENTIK_ISSUER!,
|
issuer: process.env.AUTHENTIK_ISSUER!,
|
||||||
clientId: process.env.AUTHENTIK_CLIENT_ID!,
|
clientId: process.env.AUTHENTIK_CLIENT_ID!,
|
||||||
clientSecret: process.env.AUTHENTIK_CLIENT_SECRET!,
|
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: {
|
callbacks: {
|
||||||
|
|||||||
51
src/auth.ts
51
src/auth.ts
@@ -7,12 +7,47 @@ import { users } from "@/db/schema";
|
|||||||
import { authConfig } from "./auth.config";
|
import { authConfig } from "./auth.config";
|
||||||
import { verifyPassword } from "@/lib/auth/password";
|
import { verifyPassword } from "@/lib/auth/password";
|
||||||
import { checkRateLimit, recordAttempt } from "@/lib/auth/rate-limit";
|
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({
|
const credSchema = z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
password: z.string().min(1),
|
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({
|
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||||
...authConfig,
|
...authConfig,
|
||||||
providers: [
|
providers: [
|
||||||
@@ -50,17 +85,19 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
],
|
],
|
||||||
callbacks: {
|
callbacks: {
|
||||||
...authConfig.callbacks,
|
...authConfig.callbacks,
|
||||||
// Authentik-Login-Gate: nur vorgemerkte, aktive authentik-Konten zulassen.
|
// Authentik-Login = Admin-Zugang, gesteuert über die Authentik-GRUPPE:
|
||||||
async signIn({ user, account }) {
|
// 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") {
|
if (account?.provider === "authentik") {
|
||||||
const email = user.email;
|
const email = user.email;
|
||||||
if (!email) return false;
|
if (!email) return false;
|
||||||
const u = await db.query.users.findFirst({
|
const groups = extractGroups(profile);
|
||||||
where: eq(users.email, email),
|
if (!isAdminGroupMember(groups, env.AUTHENTIK_ADMIN_GROUP)) return false;
|
||||||
});
|
const u = await upsertAuthentikAdmin(email, user.name ?? null);
|
||||||
if (!u || !u.aktiv || u.authTyp !== "authentik") return false;
|
user.id = u.id;
|
||||||
user.role = u.rolle;
|
user.role = u.rolle;
|
||||||
user.brigadeId = u.brigadeId ?? null;
|
user.brigadeId = null;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|||||||
38
src/lib/auth/__tests__/authentik.test.ts
Normal file
38
src/lib/auth/__tests__/authentik.test.ts
Normal file
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
25
src/lib/auth/authentik.ts
Normal file
25
src/lib/auth/authentik.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ const serverSchema = z.object({
|
|||||||
AUTHENTIK_ISSUER: z.string().url(),
|
AUTHENTIK_ISSUER: z.string().url(),
|
||||||
AUTHENTIK_CLIENT_ID: z.string().min(1),
|
AUTHENTIK_CLIENT_ID: z.string().min(1),
|
||||||
AUTHENTIK_CLIENT_SECRET: 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:
|
// Geo:
|
||||||
OSRM_URL: z.string().url().default("http://osrm:5000"),
|
OSRM_URL: z.string().url().default("http://osrm:5000"),
|
||||||
NOMINATIM_URL: z.string().url().default("http://nominatim:8080"),
|
NOMINATIM_URL: z.string().url().default("http://nominatim:8080"),
|
||||||
|
|||||||
Reference in New Issue
Block a user