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>
115 lines
3.4 KiB
TypeScript
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);
|
|
});
|