Files
Florian-netz/scripts/seed-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

115 lines
3.4 KiB
TypeScript

import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import { eq } from "drizzle-orm";
import { hash } from "@node-rs/argon2";
import * as schema from "../src/db/schema/index.js";
/**
* Seed für Auth-Erstkonten (idempotent — mehrfaches Ausführen ändert keine
* Counts; Querschnittsstandard 7).
*
* - 1 platform_admin (authTyp `authentik`, brigadeId = NULL). Muss zusätzlich
* in Authentik existieren; der signIn-Callback verweigert sonst (Henne-Ei
* beim Erst-Deploy, siehe Plan, Risiken Punkt 7).
* - 1 Demo-Wehr + 1 wehr_admin (authTyp `local`, argon2id-Passworthash).
*
* Liest `DATABASE_URL` direkt aus der Umgebung (keine Next.js-Env-Validierung).
*/
const ARGON2_PARAMS = {
type: 2 as const,
memoryCost: 19456,
timeCost: 2,
parallelism: 1,
};
const PLATFORM_ADMIN_EMAIL =
process.env.SEED_PLATFORM_ADMIN_EMAIL ?? "admin@floriannetz.local";
const WEHR_ADMIN_EMAIL =
process.env.SEED_WEHR_ADMIN_EMAIL ?? "wehr-admin@floriannetz.local";
const WEHR_ADMIN_PASSWORD =
process.env.SEED_WEHR_ADMIN_PASSWORD ?? "florian-netz-demo";
const DEMO_BRIGADE_NAME =
process.env.SEED_DEMO_BRIGADE_NAME ?? "FF Demo (Seed)";
async function main(): Promise<void> {
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error("DATABASE_URL ist nicht gesetzt.");
}
const pool = new Pool({ connectionString, max: 1 });
const db = drizzle(pool, { schema });
try {
// Platform-Admin (authentik): Upsert auf E-Mail (Natural Key).
await db
.insert(schema.users)
.values({
email: PLATFORM_ADMIN_EMAIL,
name: "Plattform-Administration",
rolle: "platform_admin",
authTyp: "authentik",
brigadeId: null,
passwortHash: null,
aktiv: true,
})
.onConflictDoUpdate({
target: schema.users.email,
set: {
rolle: "platform_admin",
authTyp: "authentik",
brigadeId: null,
aktiv: true,
},
});
// Demo-Wehr: idempotent (brigades hat keinen Natural-Key-Unique → Lookup).
let brigade = await db.query.brigades.findFirst({
where: eq(schema.brigades.name, DEMO_BRIGADE_NAME),
});
if (!brigade) {
const [created] = await db
.insert(schema.brigades)
.values({ name: DEMO_BRIGADE_NAME, art: "FF" })
.returning();
brigade = created;
}
if (!brigade) throw new Error("Demo-Wehr konnte nicht angelegt werden.");
// Wehr-Admin (local) mit argon2id-Hash: Upsert auf E-Mail.
const passwortHash = await hash(WEHR_ADMIN_PASSWORD, ARGON2_PARAMS);
await db
.insert(schema.users)
.values({
email: WEHR_ADMIN_EMAIL,
name: "Wehr-Administration (Demo)",
rolle: "wehr_admin",
authTyp: "local",
brigadeId: brigade.id,
passwortHash,
aktiv: true,
})
.onConflictDoUpdate({
target: schema.users.email,
set: {
rolle: "wehr_admin",
authTyp: "local",
brigadeId: brigade.id,
passwortHash,
aktiv: true,
},
});
console.log("Auth-Seed erfolgreich (idempotent).");
console.log(` platform_admin (authentik): ${PLATFORM_ADMIN_EMAIL}`);
console.log(` wehr_admin (local): ${WEHR_ADMIN_EMAIL}`);
} finally {
await pool.end();
}
}
main().catch((err: unknown) => {
console.error("Auth-Seed fehlgeschlagen:", err);
process.exit(1);
});