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:
458
package-lock.json
generated
458
package-lock.json
generated
@@ -8,6 +8,7 @@
|
|||||||
"name": "floriannetz",
|
"name": "floriannetz",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@node-rs/argon2": "^2.0.2",
|
||||||
"@radix-ui/react-dialog": "^1.1.6",
|
"@radix-ui/react-dialog": "^1.1.6",
|
||||||
"@radix-ui/react-label": "^2.1.2",
|
"@radix-ui/react-label": "^2.1.2",
|
||||||
"@radix-ui/react-select": "^2.1.6",
|
"@radix-ui/react-select": "^2.1.6",
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"drizzle-orm": "^0.39.3",
|
"drizzle-orm": "^0.39.3",
|
||||||
"next": "^15.2.0",
|
"next": "^15.2.0",
|
||||||
|
"next-auth": "^5.0.0-beta.31",
|
||||||
"pg": "^8.13.3",
|
"pg": "^8.13.3",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
@@ -27,6 +29,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
|
"@playwright/test": "^1.60.0",
|
||||||
"@types/node": "^22.13.5",
|
"@types/node": "^22.13.5",
|
||||||
"@types/pg": "^8.11.11",
|
"@types/pg": "^8.11.11",
|
||||||
"@types/react": "^19.0.10",
|
"@types/react": "^19.0.10",
|
||||||
@@ -56,6 +59,35 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@auth/core": {
|
||||||
|
"version": "0.41.2",
|
||||||
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@auth/core/-/core-0.41.2.tgz",
|
||||||
|
"integrity": "sha512-Hx5MNBxN2fJTbJKGUKAA0wca43D0Akl3TvufY54Gn8lop7F+34vU1zA1pn0vQfIoVuLIrpfc2nkyjwIaPJMW7w==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@panva/hkdf": "^1.2.1",
|
||||||
|
"jose": "^6.0.6",
|
||||||
|
"oauth4webapi": "^3.3.0",
|
||||||
|
"preact": "10.24.3",
|
||||||
|
"preact-render-to-string": "6.5.11"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@simplewebauthn/browser": "^9.0.1",
|
||||||
|
"@simplewebauthn/server": "^9.0.2",
|
||||||
|
"nodemailer": "^7.0.7"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@simplewebauthn/browser": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@simplewebauthn/server": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"nodemailer": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@drizzle-team/brocli": {
|
"node_modules/@drizzle-team/brocli": {
|
||||||
"version": "0.10.2",
|
"version": "0.10.2",
|
||||||
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@drizzle-team/brocli/-/brocli-0.10.2.tgz",
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@drizzle-team/brocli/-/brocli-0.10.2.tgz",
|
||||||
@@ -64,10 +96,9 @@
|
|||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/wasi-threads": {
|
"node_modules/@emnapi/wasi-threads": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.2",
|
||||||
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
||||||
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1858,6 +1889,267 @@
|
|||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@node-rs/argon2": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@node-rs/argon2/-/argon2-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-t64wIsPEtNd4aUPuTAyeL2ubxATCBGmeluaKXEMAFk/8w6AJIVVkeLKMBpgLW6LU2t5cQxT+env/c6jxbtTQBg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@node-rs/argon2-android-arm-eabi": "2.0.2",
|
||||||
|
"@node-rs/argon2-android-arm64": "2.0.2",
|
||||||
|
"@node-rs/argon2-darwin-arm64": "2.0.2",
|
||||||
|
"@node-rs/argon2-darwin-x64": "2.0.2",
|
||||||
|
"@node-rs/argon2-freebsd-x64": "2.0.2",
|
||||||
|
"@node-rs/argon2-linux-arm-gnueabihf": "2.0.2",
|
||||||
|
"@node-rs/argon2-linux-arm64-gnu": "2.0.2",
|
||||||
|
"@node-rs/argon2-linux-arm64-musl": "2.0.2",
|
||||||
|
"@node-rs/argon2-linux-x64-gnu": "2.0.2",
|
||||||
|
"@node-rs/argon2-linux-x64-musl": "2.0.2",
|
||||||
|
"@node-rs/argon2-wasm32-wasi": "2.0.2",
|
||||||
|
"@node-rs/argon2-win32-arm64-msvc": "2.0.2",
|
||||||
|
"@node-rs/argon2-win32-ia32-msvc": "2.0.2",
|
||||||
|
"@node-rs/argon2-win32-x64-msvc": "2.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@node-rs/argon2-android-arm-eabi": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@node-rs/argon2-android-arm-eabi/-/argon2-android-arm-eabi-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-DV/H8p/jt40lrao5z5g6nM9dPNPGEHL+aK6Iy/og+dbL503Uj0AHLqj1Hk9aVUSCNnsDdUEKp4TVMi0YakDYKw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@node-rs/argon2-android-arm64": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@node-rs/argon2-android-arm64/-/argon2-android-arm64-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-1LKwskau+8O1ktKx7TbK7jx1oMOMt4YEXZOdSNIar1TQKxm6isZ0cRXgHLibPHEcNHgYRsJWDE9zvDGBB17QDg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@node-rs/argon2-darwin-arm64": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@node-rs/argon2-darwin-arm64/-/argon2-darwin-arm64-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-3TTNL/7wbcpNju5YcqUrCgXnXUSbD7ogeAKatzBVHsbpjZQbNb1NDxDjqqrWoTt6XL3z9mJUMGwbAk7zQltHtA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@node-rs/argon2-darwin-x64": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@node-rs/argon2-darwin-x64/-/argon2-darwin-x64-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-vNPfkLj5Ij5111UTiYuwgxMqE7DRbOS2y58O2DIySzSHbcnu+nipmRKg+P0doRq6eKIJStyBK8dQi5Ic8pFyDw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@node-rs/argon2-freebsd-x64": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@node-rs/argon2-freebsd-x64/-/argon2-freebsd-x64-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-M8vQZk01qojQfCqQU0/O1j1a4zPPrz93zc9fSINY7Q/6RhQRBCYwDw7ltDCZXg5JRGlSaeS8cUXWyhPGar3cGg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@node-rs/argon2-linux-arm-gnueabihf": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@node-rs/argon2-linux-arm-gnueabihf/-/argon2-linux-arm-gnueabihf-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-7EmmEPHLzcu0G2GDh30L6G48CH38roFC2dqlQJmtRCxs6no3tTE/pvgBGatTp/o2n2oyOJcfmgndVFcUpwMnww==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@node-rs/argon2-linux-arm64-gnu": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@node-rs/argon2-linux-arm64-gnu/-/argon2-linux-arm64-gnu-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-6lsYh3Ftbk+HAIZ7wNuRF4SZDtxtFTfK+HYFAQQyW7Ig3LHqasqwfUKRXVSV5tJ+xTnxjqgKzvZSUJCAyIfHew==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@node-rs/argon2-linux-arm64-musl": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@node-rs/argon2-linux-arm64-musl/-/argon2-linux-arm64-musl-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-p3YqVMNT/4DNR67tIHTYGbedYmXxW9QlFmF39SkXyEbGQwpgSf6pH457/fyXBIYznTU/smnG9EH+C1uzT5j4hA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@node-rs/argon2-linux-x64-gnu": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@node-rs/argon2-linux-x64-gnu/-/argon2-linux-x64-gnu-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-ZM3jrHuJ0dKOhvA80gKJqBpBRmTJTFSo2+xVZR+phQcbAKRlDMSZMFDiKbSTnctkfwNFtjgDdh5g1vaEV04AvA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@node-rs/argon2-linux-x64-musl": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@node-rs/argon2-linux-x64-musl/-/argon2-linux-x64-musl-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-of5uPqk7oCRF/44a89YlWTEfjsftPywyTULwuFDKyD8QtVZoonrJR6ZWvfFE/6jBT68S0okAkAzzMEdBVWdxWw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@node-rs/argon2-wasm32-wasi": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@node-rs/argon2-wasm32-wasi/-/argon2-wasm32-wasi-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-U3PzLYKSQYzTERstgtHLd4ZTkOF9co57zTXT77r0cVUsleGZOrd6ut7rHzeWwoJSiHOVxxa0OhG1JVQeB7lLoQ==",
|
||||||
|
"cpu": [
|
||||||
|
"wasm32"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@napi-rs/wasm-runtime": "^0.2.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@node-rs/argon2-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||||
|
"version": "0.2.12",
|
||||||
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
||||||
|
"integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/core": "^1.4.3",
|
||||||
|
"@emnapi/runtime": "^1.4.3",
|
||||||
|
"@tybys/wasm-util": "^0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@node-rs/argon2-win32-arm64-msvc": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@node-rs/argon2-win32-arm64-msvc/-/argon2-win32-arm64-msvc-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-Eisd7/NM0m23ijrGr6xI2iMocdOuyl6gO27gfMfya4C5BODbUSP7ljKJ7LrA0teqZMdYHesRDzx36Js++/vhiQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@node-rs/argon2-win32-ia32-msvc": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@node-rs/argon2-win32-ia32-msvc/-/argon2-win32-ia32-msvc-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-GsE2ezwAYwh72f9gIjbGTZOf4HxEksb5M2eCaj+Y5rGYVwAdt7C12Q2e9H5LRYxWcFvLH4m4jiSZpQQ4upnPAQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@node-rs/argon2-win32-x64-msvc": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@node-rs/argon2-win32-x64-msvc/-/argon2-win32-x64-msvc-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-cJxWXanH4Ew9CfuZ4IAEiafpOBCe97bzoKowHCGk5lG/7kR4WF/eknnBlHW9m8q7t10mKq75kruPLtbSDqgRTw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@nodelib/fs.scandir": {
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://npm.apple.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
"resolved": "https://npm.apple.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||||
@@ -1903,6 +2195,15 @@
|
|||||||
"node": ">=12.4.0"
|
"node": ">=12.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@panva/hkdf": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@panva/hkdf/-/hkdf-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@petamoriken/float16": {
|
"node_modules/@petamoriken/float16": {
|
||||||
"version": "3.9.3",
|
"version": "3.9.3",
|
||||||
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@petamoriken/float16/-/float16-3.9.3.tgz",
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@petamoriken/float16/-/float16-3.9.3.tgz",
|
||||||
@@ -1910,6 +2211,23 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.60.0",
|
||||||
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@playwright/test/-/test-1.60.0.tgz",
|
||||||
|
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
|
||||||
|
"devOptional": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.60.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/number": {
|
"node_modules/@radix-ui/number": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@radix-ui/number/-/number-1.1.2.tgz",
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@radix-ui/number/-/number-1.1.2.tgz",
|
||||||
@@ -2988,7 +3306,6 @@
|
|||||||
"version": "0.10.2",
|
"version": "0.10.2",
|
||||||
"resolved": "https://npm.apple.com/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
"resolved": "https://npm.apple.com/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
||||||
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
|
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -3619,6 +3936,18 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/core": {
|
||||||
|
"version": "1.10.0",
|
||||||
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@emnapi/core/-/core-1.10.0.tgz",
|
||||||
|
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/wasi-threads": "1.2.1",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": {
|
"node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||||
"version": "1.10.0",
|
"version": "1.10.0",
|
||||||
"resolved": "https://npm.apple.com/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
"resolved": "https://npm.apple.com/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||||
@@ -3629,6 +3958,17 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@unrs/resolver-binding-win32-arm64-msvc": {
|
"node_modules/@unrs/resolver-binding-win32-arm64-msvc": {
|
||||||
"version": "1.12.2",
|
"version": "1.12.2",
|
||||||
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz",
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz",
|
||||||
@@ -6458,6 +6798,14 @@
|
|||||||
"jiti": "bin/jiti.js"
|
"jiti": "bin/jiti.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jose": {
|
||||||
|
"version": "6.2.3",
|
||||||
|
"resolved": "https://npm.apple.com/jose/-/jose-6.2.3.tgz",
|
||||||
|
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://npm.apple.com/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://npm.apple.com/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -6803,6 +7151,32 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/next-auth": {
|
||||||
|
"version": "5.0.0-beta.31",
|
||||||
|
"resolved": "https://npm.apple.com/next-auth/-/next-auth-5.0.0-beta.31.tgz",
|
||||||
|
"integrity": "sha512-1OBgCKPzo+S7UWWMp3xgvGvIJ0OpV7B3vR4ZDRqD9a4Ch+OT6dakLXG9ivhtmIWVa71nTSXattOHyCg8sNi8/Q==",
|
||||||
|
"dependencies": {
|
||||||
|
"@auth/core": "0.41.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@simplewebauthn/browser": "^9.0.1",
|
||||||
|
"@simplewebauthn/server": "^9.0.2",
|
||||||
|
"next": "^14.0.0-0 || ^15.0.0 || ^16.0.0",
|
||||||
|
"nodemailer": "^7.0.7",
|
||||||
|
"react": "^18.2.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@simplewebauthn/browser": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@simplewebauthn/server": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"nodemailer": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/next/node_modules/postcss": {
|
"node_modules/next/node_modules/postcss": {
|
||||||
"version": "8.4.31",
|
"version": "8.4.31",
|
||||||
"resolved": "https://npm.apple.com/postcss/-/postcss-8.4.31.tgz",
|
"resolved": "https://npm.apple.com/postcss/-/postcss-8.4.31.tgz",
|
||||||
@@ -6876,6 +7250,15 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/oauth4webapi": {
|
||||||
|
"version": "3.8.6",
|
||||||
|
"resolved": "https://npm.apple.com/oauth4webapi/-/oauth4webapi-3.8.6.tgz",
|
||||||
|
"integrity": "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-assign": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://npm.apple.com/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://npm.apple.com/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
@@ -7243,6 +7626,52 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.60.0",
|
||||||
|
"resolved": "https://npm.apple.com/playwright/-/playwright-1.60.0.tgz",
|
||||||
|
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||||
|
"devOptional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.60.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.60.0",
|
||||||
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||||
|
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||||
|
"devOptional": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/possible-typed-array-names": {
|
"node_modules/possible-typed-array-names": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://npm.apple.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
"resolved": "https://npm.apple.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||||
@@ -7469,6 +7898,25 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/preact": {
|
||||||
|
"version": "10.24.3",
|
||||||
|
"resolved": "https://npm.apple.com/preact/-/preact-10.24.3.tgz",
|
||||||
|
"integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==",
|
||||||
|
"peer": true,
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/preact"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/preact-render-to-string": {
|
||||||
|
"version": "6.5.11",
|
||||||
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz",
|
||||||
|
"integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"preact": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/prelude-ls": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://npm.apple.com/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://npm.apple.com/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
|
|||||||
@@ -13,11 +13,14 @@
|
|||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "tsx scripts/migrate.ts",
|
"db:migrate": "tsx scripts/migrate.ts",
|
||||||
|
"db:seed-auth": "tsx scripts/seed-auth.ts",
|
||||||
|
"test:e2e:gating": "playwright test tests/e2e/auth-gating.spec.ts",
|
||||||
"db:push": "drizzle-kit push",
|
"db:push": "drizzle-kit push",
|
||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
"db:check": "drizzle-kit check"
|
"db:check": "drizzle-kit check"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@node-rs/argon2": "^2.0.2",
|
||||||
"@radix-ui/react-dialog": "^1.1.6",
|
"@radix-ui/react-dialog": "^1.1.6",
|
||||||
"@radix-ui/react-label": "^2.1.2",
|
"@radix-ui/react-label": "^2.1.2",
|
||||||
"@radix-ui/react-select": "^2.1.6",
|
"@radix-ui/react-select": "^2.1.6",
|
||||||
@@ -29,6 +32,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"drizzle-orm": "^0.39.3",
|
"drizzle-orm": "^0.39.3",
|
||||||
"next": "^15.2.0",
|
"next": "^15.2.0",
|
||||||
|
"next-auth": "^5.0.0-beta.31",
|
||||||
"pg": "^8.13.3",
|
"pg": "^8.13.3",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
@@ -36,11 +40,12 @@
|
|||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
|
"@playwright/test": "^1.60.0",
|
||||||
"@types/node": "^22.13.5",
|
"@types/node": "^22.13.5",
|
||||||
"@types/pg": "^8.11.11",
|
"@types/pg": "^8.11.11",
|
||||||
"@types/react": "^19.0.10",
|
"@types/react": "^19.0.10",
|
||||||
"@types/react-dom": "^19.0.4",
|
"@types/react-dom": "^19.0.4",
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"drizzle-kit": "^0.30.4",
|
"drizzle-kit": "^0.30.4",
|
||||||
"eslint": "^9.21.0",
|
"eslint": "^9.21.0",
|
||||||
|
|||||||
31
playwright.config.ts
Normal file
31
playwright.config.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Playwright-Konfiguration für die Auth-Gating-Suite.
|
||||||
|
*
|
||||||
|
* HINWEIS: Diese Suite erfordert einen LAUFENDEN Server (Next.js) und eine
|
||||||
|
* erreichbare Datenbank. In der Sandbox/CI ohne Server/DB wird sie NICHT
|
||||||
|
* ausgeführt (deferred). `webServer` startet die App lokal, wenn vorhanden.
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./tests/e2e",
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
reporter: process.env.CI ? "github" : "list",
|
||||||
|
use: {
|
||||||
|
baseURL: process.env.E2E_BASE_URL ?? "http://localhost:3000",
|
||||||
|
trace: "on-first-retry",
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
|
||||||
|
],
|
||||||
|
webServer: process.env.E2E_BASE_URL
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
command: "npm run build && npm run start",
|
||||||
|
url: "http://localhost:3000/api/health",
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 120_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
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);
|
||||||
|
});
|
||||||
@@ -1,24 +1,20 @@
|
|||||||
import { AppShell } from "@/components/layout/app-shell";
|
import { AppShell } from "@/components/layout/app-shell";
|
||||||
|
import { requireSession } from "@/lib/auth/guards";
|
||||||
|
|
||||||
// Gated App-Shell.
|
// Gated App-Shell.
|
||||||
//
|
//
|
||||||
// GUARD-SLOT (Default-deny, dreifach — Querschnittsstandard 1):
|
// GUARD-SLOT (Default-deny, dreifach — Querschnittsstandard 1):
|
||||||
// Der Auth-Workstream (Workstream 3) fügt hier als ALLERERSTE Anweisung den
|
// Der serverseitige Session-Guard ist hier die ALLERERSTE Anweisung. Er leitet
|
||||||
// serverseitigen Session-Guard ein, z. B.:
|
// anonyme Aufrufe der GANZEN (app)-Gruppe auf /login um (Verteidigung in der
|
||||||
//
|
// Tiefe — Lese-Seiten verlassen sich NICHT allein auf die Middleware).
|
||||||
// import { requireSession } from "@/lib/auth/guards";
|
|
||||||
// ...
|
|
||||||
// await requireSession(); // leitet anonyme Aufrufe auf /login um
|
|
||||||
//
|
//
|
||||||
// Kanonischer Login-Pfad: /login (siehe (auth)/login/page.tsx). Dieselbe Route
|
// Kanonischer Login-Pfad: /login (siehe (auth)/login/page.tsx). Dieselbe Route
|
||||||
// nutzen NextAuth pages.signIn, PUBLIC_ALLOWLIST und die Gating-Specs.
|
// nutzen NextAuth pages.signIn, die Middleware-Allowlist und die Gating-Specs.
|
||||||
//
|
|
||||||
// Lese-Seiten dürfen sich NICHT allein auf die Middleware verlassen.
|
|
||||||
export default async function AppLayout({
|
export default async function AppLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
// await requireSession(); // <- vom Auth-Workstream zu aktivieren
|
await requireSession(); // Default-deny: leitet anonyme Aufrufe auf /login um
|
||||||
return <AppShell>{children}</AppShell>;
|
return <AppShell>{children}</AppShell>;
|
||||||
}
|
}
|
||||||
|
|||||||
49
src/app/(auth)/login/actions.ts
Normal file
49
src/app/(auth)/login/actions.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { AuthError } from "next-auth";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { signIn } from "@/auth";
|
||||||
|
import { t } from "@/lib/i18n/de";
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type LoginState = { error: string } | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server Action für den Authentik-OIDC-Login. Leitet zum Provider weiter; das
|
||||||
|
* `signIn`-Gate in `auth.ts` lässt nur vorgemerkte, aktive authentik-Konten zu.
|
||||||
|
*/
|
||||||
|
export async function authentikLoginAction(): Promise<void> {
|
||||||
|
await signIn("authentik", { redirectTo: "/" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginAction(
|
||||||
|
_prev: LoginState,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<LoginState> {
|
||||||
|
const parsed = loginSchema.safeParse({
|
||||||
|
email: formData.get("email"),
|
||||||
|
password: formData.get("password"),
|
||||||
|
});
|
||||||
|
if (!parsed.success) {
|
||||||
|
return { error: t("auth.fehlgeschlagen") };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await signIn("credentials", {
|
||||||
|
email: parsed.data.email,
|
||||||
|
password: parsed.data.password,
|
||||||
|
redirectTo: "/",
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return { error: t("auth.fehlgeschlagen") };
|
||||||
|
}
|
||||||
|
// NEXT_REDIRECT u. ä. müssen weitergereicht werden.
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/app/(auth)/login/login-form.tsx
Normal file
56
src/app/(auth)/login/login-form.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState } from "react";
|
||||||
|
import { loginAction, type LoginState } from "./actions";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { t } from "@/lib/i18n/de";
|
||||||
|
|
||||||
|
export function LoginForm() {
|
||||||
|
const [state, formAction, pending] = useActionState<LoginState, FormData>(
|
||||||
|
loginAction,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={formAction} className="mt-6 space-y-4">
|
||||||
|
{state?.error ? (
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
className="rounded-sm border border-signal/30 bg-signal/5 px-3 py-2 text-sm text-signal"
|
||||||
|
>
|
||||||
|
{state.error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="email">{t("auth.email")}</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="password">{t("auth.passwort")}</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
disabled={pending}
|
||||||
|
>
|
||||||
|
{t("auth.anmelden")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,33 +1,33 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { authentikLoginAction } from "./actions";
|
||||||
|
import { LoginForm } from "./login-form";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { t } from "@/lib/i18n/de";
|
import { t } from "@/lib/i18n/de";
|
||||||
|
|
||||||
// Platzhalter-Anmeldeseite. Der Auth-Workstream (Workstream 3) ersetzt die
|
// Anmeldeseite (öffentlich, in der Middleware-Allowlist). Bietet Authentik-OIDC
|
||||||
// Logik durch Authentik-OIDC bzw. lokalen Login.
|
// (Platform-Admins) und lokalen Credentials-Login (Wehr-Konten).
|
||||||
export default function AnmeldenPage() {
|
export default async function AnmeldenPage() {
|
||||||
|
// Bereits angemeldete Nutzer nicht erneut anmelden lassen.
|
||||||
|
const session = await auth();
|
||||||
|
if (session?.user) redirect("/");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="font-display text-xl text-navy">{t("auth.anmelden")}</h1>
|
<h1 className="font-display text-xl text-navy">{t("auth.anmelden")}</h1>
|
||||||
<p className="mt-1 text-sm text-anthrazit/70">{t("auth.erforderlich")}</p>
|
<p className="mt-1 text-sm text-anthrazit/70">{t("auth.erforderlich")}</p>
|
||||||
<form className="mt-6 space-y-4">
|
|
||||||
<div className="space-y-1.5">
|
<form action={authentikLoginAction} className="mt-6">
|
||||||
<Label htmlFor="email">E-Mail</Label>
|
<Button type="submit" variant="primary" className="w-full">
|
||||||
<Input id="email" name="email" type="email" autoComplete="username" />
|
{t("auth.mitAuthentik")}
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="passwort">Passwort</Label>
|
|
||||||
<Input
|
|
||||||
id="passwort"
|
|
||||||
name="passwort"
|
|
||||||
type="password"
|
|
||||||
autoComplete="current-password"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button type="submit" className="w-full" disabled>
|
|
||||||
{t("auth.anmelden")}
|
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<p className="mt-4 text-center text-xs text-anthrazit/60">
|
||||||
|
{t("auth.oderLokal")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<LoginForm />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { handlers } from "@/auth";
|
||||||
|
|
||||||
|
export const { GET, POST } = handlers;
|
||||||
9
src/app/api/health/route.ts
Normal file
9
src/app/api/health/route.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
// Öffentlicher Health-Check (anonym 200). In der Middleware-Allowlist; dient
|
||||||
|
// Container-/Reverse-Proxy-Health-Probes. Enthält bewusst keine Fachdaten.
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export function GET() {
|
||||||
|
return NextResponse.json({ status: "ok" });
|
||||||
|
}
|
||||||
50
src/auth.config.ts
Normal file
50
src/auth.config.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import type { NextAuthConfig } from "next-auth";
|
||||||
|
import Authentik from "next-auth/providers/authentik";
|
||||||
|
import { isHttps } from "@/lib/env";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edge-sichere Auth-Konfiguration. Dieses Modul wird auch in der Middleware
|
||||||
|
* (Edge-Runtime) verwendet und darf daher WEDER `@/db` NOCH `@node-rs/argon2`
|
||||||
|
* importieren (siehe Verifikation Workstream 3, Schritt 2).
|
||||||
|
*
|
||||||
|
* Cookie-`secure` und `__Secure-`-Präfix sind umgebungsabhängig (`isHttps`),
|
||||||
|
* damit lokale HTTP-Entwicklung nicht bricht (Querschnittsstandard 9).
|
||||||
|
*/
|
||||||
|
export const authConfig = {
|
||||||
|
trustHost: true,
|
||||||
|
pages: { signIn: "/login", error: "/login" },
|
||||||
|
session: { strategy: "jwt", maxAge: 60 * 60 * 8 },
|
||||||
|
cookies: {
|
||||||
|
sessionToken: {
|
||||||
|
name: isHttps ? "__Secure-floriannetz.session" : "floriannetz.session",
|
||||||
|
options: {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/",
|
||||||
|
secure: isHttps,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
providers: [
|
||||||
|
Authentik({
|
||||||
|
issuer: process.env.AUTHENTIK_ISSUER!,
|
||||||
|
clientId: process.env.AUTHENTIK_CLIENT_ID!,
|
||||||
|
clientSecret: process.env.AUTHENTIK_CLIENT_SECRET!,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
callbacks: {
|
||||||
|
authorized: ({ auth }) => !!auth?.user,
|
||||||
|
async jwt({ token, user }) {
|
||||||
|
if (user) {
|
||||||
|
token.role = user.role;
|
||||||
|
token.brigadeId = user.brigadeId ?? null;
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
},
|
||||||
|
async session({ session, token }) {
|
||||||
|
session.user.role = token.role;
|
||||||
|
session.user.brigadeId = token.brigadeId ?? null;
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies NextAuthConfig;
|
||||||
68
src/auth.ts
Normal file
68
src/auth.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import NextAuth from "next-auth";
|
||||||
|
import Credentials from "next-auth/providers/credentials";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { users } from "@/db/schema";
|
||||||
|
import { authConfig } from "./auth.config";
|
||||||
|
import { verifyPassword } from "@/lib/auth/password";
|
||||||
|
import { checkRateLimit, recordAttempt } from "@/lib/auth/rate-limit";
|
||||||
|
|
||||||
|
const credSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||||
|
...authConfig,
|
||||||
|
providers: [
|
||||||
|
...authConfig.providers,
|
||||||
|
Credentials({
|
||||||
|
credentials: { email: {}, password: {} },
|
||||||
|
authorize: async (raw) => {
|
||||||
|
const parsed = credSchema.safeParse(raw);
|
||||||
|
if (!parsed.success) return null;
|
||||||
|
const { email, password } = parsed.data;
|
||||||
|
const key = `email:${email.toLowerCase()}`;
|
||||||
|
// Rate-Limit greift für ALLE Credentials-Logins (default-deny).
|
||||||
|
if (!(await checkRateLimit(key))) return null;
|
||||||
|
const u = await db.query.users.findFirst({
|
||||||
|
where: eq(users.email, email),
|
||||||
|
});
|
||||||
|
if (!u || !u.aktiv || u.authTyp !== "local" || !u.passwortHash) {
|
||||||
|
await recordAttempt(key, "fail");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!(await verifyPassword(u.passwortHash, password))) {
|
||||||
|
await recordAttempt(key, "fail");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
await recordAttempt(key, "ok");
|
||||||
|
return {
|
||||||
|
id: u.id,
|
||||||
|
email: u.email,
|
||||||
|
name: u.name,
|
||||||
|
role: u.rolle,
|
||||||
|
brigadeId: u.brigadeId,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
callbacks: {
|
||||||
|
...authConfig.callbacks,
|
||||||
|
// Authentik-Login-Gate: nur vorgemerkte, aktive authentik-Konten zulassen.
|
||||||
|
async signIn({ user, account }) {
|
||||||
|
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;
|
||||||
|
user.role = u.rolle;
|
||||||
|
user.brigadeId = u.brigadeId ?? null;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
27
src/lib/auth/__tests__/password.test.ts
Normal file
27
src/lib/auth/__tests__/password.test.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { hashPassword, verifyPassword, ARGON2_PARAMS } from "../password";
|
||||||
|
|
||||||
|
describe("password (argon2id)", () => {
|
||||||
|
it("verwendet OWASP-Minima für argon2id", () => {
|
||||||
|
// type 2 === argon2id
|
||||||
|
expect(ARGON2_PARAMS.type).toBe(2);
|
||||||
|
expect(ARGON2_PARAMS.memoryCost).toBeGreaterThanOrEqual(19456);
|
||||||
|
expect(ARGON2_PARAMS.timeCost).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(ARGON2_PARAMS.parallelism).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("erzeugt einen argon2id-Hash mit korrektem Präfix", async () => {
|
||||||
|
const h = await hashPassword("geheimes-passwort");
|
||||||
|
expect(h.startsWith("$argon2id$")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verifiziert das korrekte Passwort", async () => {
|
||||||
|
const h = await hashPassword("richtig");
|
||||||
|
expect(await verifyPassword(h, "richtig")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lehnt ein falsches Passwort ab", async () => {
|
||||||
|
const h = await hashPassword("richtig");
|
||||||
|
expect(await verifyPassword(h, "falsch")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
19
src/lib/auth/__tests__/rate-limit.test.ts
Normal file
19
src/lib/auth/__tests__/rate-limit.test.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { RATE_LIMIT, isWithinLimit } from "../rate-limit";
|
||||||
|
|
||||||
|
describe("rate-limit policy", () => {
|
||||||
|
it("erlaubt max. 5 Fehlversuche pro Fenster", () => {
|
||||||
|
expect(RATE_LIMIT.maxFails).toBe(5);
|
||||||
|
expect(RATE_LIMIT.windowMs).toBe(15 * 60 * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ist innerhalb des Limits bei < maxFails", () => {
|
||||||
|
expect(isWithinLimit(0)).toBe(true);
|
||||||
|
expect(isWithinLimit(4)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blockiert ab maxFails (6. Versuch)", () => {
|
||||||
|
expect(isWithinLimit(5)).toBe(false);
|
||||||
|
expect(isWithinLimit(6)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
40
src/lib/auth/__tests__/roles.test.ts
Normal file
40
src/lib/auth/__tests__/roles.test.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { ROLES, ALL_ROLES, isRole, canAccessBrigade } from "../roles";
|
||||||
|
|
||||||
|
describe("roles", () => {
|
||||||
|
it("definiert die drei Rollen als Single Source of Truth", () => {
|
||||||
|
expect(ROLES).toEqual({
|
||||||
|
PLATFORM_ADMIN: "platform_admin",
|
||||||
|
WEHR_ADMIN: "wehr_admin",
|
||||||
|
WEHR_READ: "wehr_read",
|
||||||
|
});
|
||||||
|
expect(ALL_ROLES).toEqual(["platform_admin", "wehr_admin", "wehr_read"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isRole erkennt gültige Rollen", () => {
|
||||||
|
expect(isRole("platform_admin")).toBe(true);
|
||||||
|
expect(isRole("wehr_admin")).toBe(true);
|
||||||
|
expect(isRole("wehr_read")).toBe(true);
|
||||||
|
expect(isRole("root")).toBe(false);
|
||||||
|
expect(isRole(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("canAccessBrigade (Wehr-Scoping)", () => {
|
||||||
|
it("platform_admin darf jede Wehr", () => {
|
||||||
|
expect(
|
||||||
|
canAccessBrigade("platform_admin", null, "wehr-b"),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
it("wehr_admin darf nur die eigene Wehr", () => {
|
||||||
|
expect(canAccessBrigade("wehr_admin", "wehr-a", "wehr-a")).toBe(true);
|
||||||
|
expect(canAccessBrigade("wehr_admin", "wehr-a", "wehr-b")).toBe(false);
|
||||||
|
});
|
||||||
|
it("wehr_read darf nur die eigene Wehr", () => {
|
||||||
|
expect(canAccessBrigade("wehr_read", "wehr-a", "wehr-a")).toBe(true);
|
||||||
|
expect(canAccessBrigade("wehr_read", "wehr-a", "wehr-b")).toBe(false);
|
||||||
|
});
|
||||||
|
it("ohne brigadeId verweigert (default-deny)", () => {
|
||||||
|
expect(canAccessBrigade("wehr_admin", null, "wehr-a")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
102
src/lib/auth/guards.ts
Normal file
102
src/lib/auth/guards.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { redirect, forbidden } from "next/navigation";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type { Session } from "next-auth";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { canAccessBrigade, type Role } from "./roles";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vollständiger Guard-Satz (default-deny, Querschnittsstandard 1–3).
|
||||||
|
*
|
||||||
|
* - Server Components / Server Actions nutzen `requireSession`/`requireRole`/…
|
||||||
|
* (sie werfen `redirect("/login")` bzw. `forbidden()` → 403).
|
||||||
|
* - API-Routen nutzen `apiAuth` / `apiRequireRole` / `apiRequireOwnBrigade`,
|
||||||
|
* die `NextResponse` mit 401/403 OHNE Fachdaten zurückgeben.
|
||||||
|
*/
|
||||||
|
export type AppSession = Session & {
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
role: Role;
|
||||||
|
brigadeId: string | null;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Server Components / Server Actions ─────────────────────────────────────
|
||||||
|
|
||||||
|
export async function requireSession(): Promise<AppSession> {
|
||||||
|
const s = await auth();
|
||||||
|
if (!s?.user) redirect("/login");
|
||||||
|
return s as AppSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireRole(...allowed: Role[]): Promise<AppSession> {
|
||||||
|
const s = await requireSession();
|
||||||
|
if (!allowed.includes(s.user.role)) forbidden(); // 403
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requirePlatformAdmin(): Promise<AppSession> {
|
||||||
|
return requireRole("platform_admin");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireWehrAdmin(): Promise<
|
||||||
|
AppSession & { user: AppSession["user"] & { brigadeId: string } }
|
||||||
|
> {
|
||||||
|
const s = await requireRole("wehr_admin");
|
||||||
|
if (!s.user.brigadeId) forbidden();
|
||||||
|
return s as AppSession & { user: AppSession["user"] & { brigadeId: string } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireOwnBrigade(
|
||||||
|
brigadeId: string,
|
||||||
|
): Promise<AppSession> {
|
||||||
|
const s = await requireRole("wehr_admin", "platform_admin");
|
||||||
|
if (!canAccessBrigade(s.user.role, s.user.brigadeId, brigadeId)) forbidden();
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API-Routen (401/403 ohne Daten-Leak, Querschnittsstandard 2) ───────────
|
||||||
|
|
||||||
|
export type ApiAuthResult =
|
||||||
|
| { ok: true; session: AppSession }
|
||||||
|
| { ok: false; response: NextResponse };
|
||||||
|
|
||||||
|
const unauthorized = () =>
|
||||||
|
NextResponse.json({ error: "Anmeldung erforderlich." }, { status: 401 });
|
||||||
|
const forbiddenResponse = () =>
|
||||||
|
NextResponse.json({ error: "Keine Berechtigung." }, { status: 403 });
|
||||||
|
|
||||||
|
export async function apiAuth(): Promise<ApiAuthResult> {
|
||||||
|
const s = await auth();
|
||||||
|
if (!s?.user) return { ok: false, response: unauthorized() };
|
||||||
|
return { ok: true, session: s as AppSession };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiRequireRole(
|
||||||
|
...allowed: Role[]
|
||||||
|
): Promise<ApiAuthResult> {
|
||||||
|
const result = await apiAuth();
|
||||||
|
if (!result.ok) return result;
|
||||||
|
if (!allowed.includes(result.session.user.role)) {
|
||||||
|
return { ok: false, response: forbiddenResponse() };
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiRequireOwnBrigade(
|
||||||
|
brigadeId: string,
|
||||||
|
): Promise<ApiAuthResult> {
|
||||||
|
const result = await apiRequireRole("wehr_admin", "platform_admin");
|
||||||
|
if (!result.ok) return result;
|
||||||
|
if (
|
||||||
|
!canAccessBrigade(
|
||||||
|
result.session.user.role,
|
||||||
|
result.session.user.brigadeId,
|
||||||
|
brigadeId,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return { ok: false, response: forbiddenResponse() };
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
21
src/lib/auth/password.ts
Normal file
21
src/lib/auth/password.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { hash, verify } from "@node-rs/argon2";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* argon2id mit OWASP-Minima (Querschnittsstandard 11):
|
||||||
|
* type=argon2id (2), memoryCost >= 19456 KiB, timeCost >= 2, parallelism >= 1.
|
||||||
|
*
|
||||||
|
* WICHTIG: Dieses Modul NIE im Edge-/Middleware-Pfad importieren — @node-rs/argon2
|
||||||
|
* ist ein natives Node-Modul.
|
||||||
|
*/
|
||||||
|
export const ARGON2_PARAMS = {
|
||||||
|
type: 2 as const, // 2 === argon2id
|
||||||
|
memoryCost: 19456,
|
||||||
|
timeCost: 2,
|
||||||
|
parallelism: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const hashPassword = (pw: string): Promise<string> =>
|
||||||
|
hash(pw, ARGON2_PARAMS);
|
||||||
|
|
||||||
|
export const verifyPassword = (h: string, pw: string): Promise<boolean> =>
|
||||||
|
verify(h, pw, ARGON2_PARAMS);
|
||||||
47
src/lib/auth/rate-limit.ts
Normal file
47
src/lib/auth/rate-limit.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { and, eq, gte, sql } from "drizzle-orm";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { loginAttempts } from "@/db/schema";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login-Rate-Limit (Sliding Window) — greift im `authorize`-Callback und damit
|
||||||
|
* für BEIDE Credentials-Login-Pfade. Liest/schreibt `login_attempts`
|
||||||
|
* (Workstream-2-Schema, hier NICHT neu definiert).
|
||||||
|
*
|
||||||
|
* Querschnittsstandard 8 (Härtung): 5 Fehlversuche / 15 min pro `key`.
|
||||||
|
*/
|
||||||
|
export const RATE_LIMIT = {
|
||||||
|
maxFails: 5,
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Reine Policy-Funktion (DB-frei, testbar): innerhalb des Limits? */
|
||||||
|
export function isWithinLimit(failCount: number): boolean {
|
||||||
|
return failCount < RATE_LIMIT.maxFails;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, ob für `key` im aktuellen Fenster noch Versuche erlaubt sind.
|
||||||
|
* Gibt `true` zurück, wenn der Login fortgesetzt werden darf.
|
||||||
|
*/
|
||||||
|
export async function checkRateLimit(key: string): Promise<boolean> {
|
||||||
|
const since = new Date(Date.now() - RATE_LIMIT.windowMs);
|
||||||
|
const [row] = await db
|
||||||
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
|
.from(loginAttempts)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(loginAttempts.key, key),
|
||||||
|
eq(loginAttempts.erfolg, false),
|
||||||
|
gte(loginAttempts.zeitpunkt, since),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return isWithinLimit(row?.count ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Protokolliert einen Login-Versuch. */
|
||||||
|
export async function recordAttempt(
|
||||||
|
key: string,
|
||||||
|
result: "ok" | "fail",
|
||||||
|
): Promise<void> {
|
||||||
|
await db.insert(loginAttempts).values({ key, erfolg: result === "ok" });
|
||||||
|
}
|
||||||
35
src/lib/auth/roles.ts
Normal file
35
src/lib/auth/roles.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { roleEnum } from "@/db/schema";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single Source of Truth für Rollen. Die zulässigen Werte stammen aus dem
|
||||||
|
* DB-Enum `role` (Workstream 2) und werden hier NICHT neu definiert, sondern
|
||||||
|
* abgeleitet, damit DB und Anwendung garantiert übereinstimmen.
|
||||||
|
*/
|
||||||
|
export const ALL_ROLES = roleEnum.enumValues;
|
||||||
|
export type Role = (typeof ALL_ROLES)[number];
|
||||||
|
|
||||||
|
export const ROLES = {
|
||||||
|
PLATFORM_ADMIN: "platform_admin",
|
||||||
|
WEHR_ADMIN: "wehr_admin",
|
||||||
|
WEHR_READ: "wehr_read",
|
||||||
|
} as const satisfies Record<string, Role>;
|
||||||
|
|
||||||
|
export function isRole(value: unknown): value is Role {
|
||||||
|
return (
|
||||||
|
typeof value === "string" && (ALL_ROLES as readonly string[]).includes(value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wehr-Scoping (default-deny): platform_admin darf wehrübergreifend; alle
|
||||||
|
* anderen Rollen nur die eigene Wehr. Ohne eigene `brigadeId` wird verweigert.
|
||||||
|
*/
|
||||||
|
export function canAccessBrigade(
|
||||||
|
role: Role,
|
||||||
|
ownBrigadeId: string | null,
|
||||||
|
targetBrigadeId: string,
|
||||||
|
): boolean {
|
||||||
|
if (role === ROLES.PLATFORM_ADMIN) return true;
|
||||||
|
if (!ownBrigadeId) return false;
|
||||||
|
return ownBrigadeId === targetBrigadeId;
|
||||||
|
}
|
||||||
@@ -11,6 +11,12 @@ export const de = {
|
|||||||
anmelden: "Anmelden",
|
anmelden: "Anmelden",
|
||||||
abmelden: "Abmelden",
|
abmelden: "Abmelden",
|
||||||
erforderlich: "Anmeldung erforderlich.",
|
erforderlich: "Anmeldung erforderlich.",
|
||||||
|
email: "E-Mail",
|
||||||
|
passwort: "Passwort",
|
||||||
|
mitAuthentik: "Mit Authentik anmelden",
|
||||||
|
oderLokal: "oder mit lokalem Konto",
|
||||||
|
fehlgeschlagen: "Anmeldung fehlgeschlagen. Bitte Eingaben prüfen.",
|
||||||
|
zuVieleVersuche: "Zu viele Fehlversuche. Bitte später erneut versuchen.",
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
einsatzbereit: "einsatzbereit",
|
einsatzbereit: "einsatzbereit",
|
||||||
|
|||||||
15
src/middleware.ts
Normal file
15
src/middleware.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import NextAuth from "next-auth";
|
||||||
|
import { authConfig } from "./auth.config";
|
||||||
|
|
||||||
|
// Edge-sichere Middleware (KEIN DB-/argon2-Import). Default-deny-Schicht 1:
|
||||||
|
// nicht authentifizierte Aufrufe werden über `authorized` auf /login geleitet.
|
||||||
|
export const { auth: middleware } = NextAuth(authConfig);
|
||||||
|
|
||||||
|
export default middleware(() => {});
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
// Allowlist: NextAuth-Endpunkte, Health-Check, Login-Seite, Next-Statics.
|
||||||
|
matcher: [
|
||||||
|
"/((?!api/auth|api/health|login|_next/static|_next/image|favicon.ico|.*\\.(?:png|svg|ico|jpg|webp|woff2?)$).*)",
|
||||||
|
],
|
||||||
|
};
|
||||||
31
src/types/next-auth.d.ts
vendored
Normal file
31
src/types/next-auth.d.ts
vendored
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { type Role } from "@/lib/auth/roles";
|
||||||
|
import type { DefaultSession } from "next-auth";
|
||||||
|
|
||||||
|
declare module "next-auth" {
|
||||||
|
interface Session {
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
role: Role;
|
||||||
|
brigadeId: string | null;
|
||||||
|
} & DefaultSession["user"];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
role: Role;
|
||||||
|
brigadeId: string | null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "next-auth/jwt" {
|
||||||
|
interface JWT {
|
||||||
|
role: Role;
|
||||||
|
brigadeId: string | null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "@auth/core/jwt" {
|
||||||
|
interface JWT {
|
||||||
|
role: Role;
|
||||||
|
brigadeId: string | null;
|
||||||
|
}
|
||||||
|
}
|
||||||
76
tests/e2e/auth-gating.spec.ts
Normal file
76
tests/e2e/auth-gating.spec.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auth-Gating-Garantie (Definition of Done, oberstes Prinzip).
|
||||||
|
*
|
||||||
|
* NICHT in der Sandbox ausführbar (kein Server/DB) — deferred. Wird über
|
||||||
|
* `npm run test:e2e:gating` gegen einen laufenden Server ausgeführt.
|
||||||
|
*
|
||||||
|
* Kerngarantie (Querschnittsstandard 1–3, default-deny dreifach):
|
||||||
|
* - Anonyme Aufrufe von Seiten -> Redirect auf /login (mit callbackUrl).
|
||||||
|
* - Anonyme Aufrufe von API-Routen -> 401 OHNE Daten-Leak.
|
||||||
|
* - Öffentliche Routen (Login, Health) bleiben erreichbar.
|
||||||
|
*
|
||||||
|
* Negativ-Probe (manuell/CI): Entfernen von `requireSession()` aus
|
||||||
|
* `(app)/layout.tsx` muss diese Suite rot machen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Geschützte Seiten (Redirect-Manifest). Neue Seiten hier ergänzen.
|
||||||
|
const PROTECTED_PAGES = [
|
||||||
|
"/",
|
||||||
|
"/start",
|
||||||
|
"/fahrzeuge",
|
||||||
|
"/geraete",
|
||||||
|
"/wehren",
|
||||||
|
"/verwaltung",
|
||||||
|
"/admin",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Geschützte API-Routen (401-Manifest). Neue API-Routen hier ergänzen.
|
||||||
|
const PROTECTED_API = ["/api/fahrzeuge", "/api/geraete", "/api/verwaltung"];
|
||||||
|
|
||||||
|
// Öffentliche Routen (Middleware-Allowlist).
|
||||||
|
const PUBLIC_ROUTES = ["/login", "/api/health"];
|
||||||
|
|
||||||
|
test.describe("Default-deny: geschützte Seiten", () => {
|
||||||
|
for (const path of PROTECTED_PAGES) {
|
||||||
|
test(`anonymer Aufruf von ${path} leitet auf /login um`, async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto(path);
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Default-deny: geschützte API-Routen", () => {
|
||||||
|
for (const path of PROTECTED_API) {
|
||||||
|
test(`anonymer Aufruf von ${path} liefert 401 ohne Daten-Leak`, async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const res = await request.get(path);
|
||||||
|
expect(res.status()).toBe(401);
|
||||||
|
const body = await res.text();
|
||||||
|
// Kein Daten-Leak: nur eine generische Fehlermeldung.
|
||||||
|
expect(body).not.toContain("brigade");
|
||||||
|
expect(body).not.toContain("passwort");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Öffentliche Routen bleiben erreichbar", () => {
|
||||||
|
test("Login-Seite ist anonym erreichbar", async ({ page }) => {
|
||||||
|
await page.goto("/login");
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "Anmelden" }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Health-Check ist anonym 200", async ({ request }) => {
|
||||||
|
const res = await request.get("/api/health");
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
expect(await res.json()).toEqual({ status: "ok" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
void PUBLIC_ROUTES;
|
||||||
@@ -10,6 +10,7 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
environment: "node",
|
environment: "node",
|
||||||
globals: true,
|
globals: true,
|
||||||
|
setupFiles: ["./vitest.setup.ts"],
|
||||||
include: ["src/**/*.test.ts", "src/**/__tests__/**/*.test.ts"],
|
include: ["src/**/*.test.ts", "src/**/__tests__/**/*.test.ts"],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
17
vitest.setup.ts
Normal file
17
vitest.setup.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
// Vitest-Setup: stellt Test-Env bereit, damit Module, die `@/lib/env` bzw.
|
||||||
|
// `@/db` importieren, beim Laden nicht an der Env-Validierung scheitern.
|
||||||
|
// Es wird KEINE echte DB-Verbindung geöffnet (Pool ist lazy bis zur Query).
|
||||||
|
const TEST_ENV: Record<string, string> = {
|
||||||
|
NODE_ENV: "test",
|
||||||
|
DATABASE_URL: "postgres://test:test@localhost:5432/test",
|
||||||
|
AUTH_SECRET: "test-secret-mindestens-32-zeichen-lang-xxxx",
|
||||||
|
AUTH_URL: "http://localhost:3000",
|
||||||
|
AUTH_TRUST_HOST: "true",
|
||||||
|
AUTHENTIK_ISSUER: "http://localhost:9000/application/o/floriannetz/",
|
||||||
|
AUTHENTIK_CLIENT_ID: "floriannetz",
|
||||||
|
AUTHENTIK_CLIENT_SECRET: "test",
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [k, v] of Object.entries(TEST_ENV)) {
|
||||||
|
if (process.env[k] === undefined) process.env[k] = v;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user