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:
114
scripts/seed-auth.ts
Normal file
114
scripts/seed-auth.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user