diff --git a/package-lock.json b/package-lock.json index 007fe93..91e74fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "floriannetz", "version": "0.1.0", "dependencies": { + "@node-rs/argon2": "^2.0.2", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-label": "^2.1.2", "@radix-ui/react-select": "^2.1.6", @@ -19,6 +20,7 @@ "clsx": "^2.1.1", "drizzle-orm": "^0.39.3", "next": "^15.2.0", + "next-auth": "^5.0.0-beta.31", "pg": "^8.13.3", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -27,6 +29,7 @@ }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", + "@playwright/test": "^1.60.0", "@types/node": "^22.13.5", "@types/pg": "^8.11.11", "@types/react": "^19.0.10", @@ -56,6 +59,35 @@ "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": { "version": "0.10.2", "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" }, "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, + "version": "1.2.2", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "license": "MIT", "optional": true, "dependencies": { @@ -1858,6 +1889,267 @@ "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": { "version": "2.1.5", "resolved": "https://npm.apple.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1903,6 +2195,15 @@ "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": { "version": "3.9.3", "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@petamoriken/float16/-/float16-3.9.3.tgz", @@ -1910,6 +2211,23 @@ "dev": true, "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": { "version": "1.1.2", "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", "resolved": "https://npm.apple.com/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -3619,6 +3936,18 @@ "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": { "version": "1.10.0", "resolved": "https://npm.apple.com/@emnapi/runtime/-/runtime-1.10.0.tgz", @@ -3629,6 +3958,17 @@ "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": { "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", @@ -6458,6 +6798,14 @@ "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": { "version": "4.0.0", "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": { "version": "8.4.31", "resolved": "https://npm.apple.com/postcss/-/postcss-8.4.31.tgz", @@ -6876,6 +7250,15 @@ "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": { "version": "4.1.1", "resolved": "https://npm.apple.com/object-assign/-/object-assign-4.1.1.tgz", @@ -7243,6 +7626,52 @@ "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": { "version": "1.1.0", "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_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": { "version": "1.2.1", "resolved": "https://npm.apple.com/prelude-ls/-/prelude-ls-1.2.1.tgz", diff --git a/package.json b/package.json index 36b4c1e..93466bd 100644 --- a/package.json +++ b/package.json @@ -13,11 +13,14 @@ "test:watch": "vitest", "db:generate": "drizzle-kit generate", "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:studio": "drizzle-kit studio", "db:check": "drizzle-kit check" }, "dependencies": { + "@node-rs/argon2": "^2.0.2", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-label": "^2.1.2", "@radix-ui/react-select": "^2.1.6", @@ -29,6 +32,7 @@ "clsx": "^2.1.1", "drizzle-orm": "^0.39.3", "next": "^15.2.0", + "next-auth": "^5.0.0-beta.31", "pg": "^8.13.3", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -36,11 +40,12 @@ "zod": "^3.24.2" }, "devDependencies": { + "@eslint/eslintrc": "^3.2.0", + "@playwright/test": "^1.60.0", "@types/node": "^22.13.5", "@types/pg": "^8.11.11", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", - "@eslint/eslintrc": "^3.2.0", "autoprefixer": "^10.4.20", "drizzle-kit": "^0.30.4", "eslint": "^9.21.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..2602b34 --- /dev/null +++ b/playwright.config.ts @@ -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, + }, +}); diff --git a/scripts/seed-auth.ts b/scripts/seed-auth.ts new file mode 100644 index 0000000..8c1f958 --- /dev/null +++ b/scripts/seed-auth.ts @@ -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 { + 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); +}); diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index 9861d56..ade5071 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -1,24 +1,20 @@ import { AppShell } from "@/components/layout/app-shell"; +import { requireSession } from "@/lib/auth/guards"; // Gated App-Shell. // // GUARD-SLOT (Default-deny, dreifach — Querschnittsstandard 1): -// Der Auth-Workstream (Workstream 3) fügt hier als ALLERERSTE Anweisung den -// serverseitigen Session-Guard ein, z. B.: -// -// import { requireSession } from "@/lib/auth/guards"; -// ... -// await requireSession(); // leitet anonyme Aufrufe auf /login um +// Der serverseitige Session-Guard ist hier die ALLERERSTE Anweisung. Er leitet +// anonyme Aufrufe der GANZEN (app)-Gruppe auf /login um (Verteidigung in der +// Tiefe — Lese-Seiten verlassen sich NICHT allein auf die Middleware). // // Kanonischer Login-Pfad: /login (siehe (auth)/login/page.tsx). Dieselbe Route -// nutzen NextAuth pages.signIn, PUBLIC_ALLOWLIST und die Gating-Specs. -// -// Lese-Seiten dürfen sich NICHT allein auf die Middleware verlassen. +// nutzen NextAuth pages.signIn, die Middleware-Allowlist und die Gating-Specs. export default async function AppLayout({ children, }: { children: React.ReactNode; }) { - // await requireSession(); // <- vom Auth-Workstream zu aktivieren + await requireSession(); // Default-deny: leitet anonyme Aufrufe auf /login um return {children}; } diff --git a/src/app/(auth)/login/actions.ts b/src/app/(auth)/login/actions.ts new file mode 100644 index 0000000..b86f2a1 --- /dev/null +++ b/src/app/(auth)/login/actions.ts @@ -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 { + await signIn("authentik", { redirectTo: "/" }); +} + +export async function loginAction( + _prev: LoginState, + formData: FormData, +): Promise { + 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; + } +} diff --git a/src/app/(auth)/login/login-form.tsx b/src/app/(auth)/login/login-form.tsx new file mode 100644 index 0000000..49bdb0f --- /dev/null +++ b/src/app/(auth)/login/login-form.tsx @@ -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( + loginAction, + null, + ); + + return ( +
+ {state?.error ? ( +

+ {state.error} +

+ ) : null} +
+ + +
+
+ + +
+ +
+ ); +} diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index e02e96d..0f22f57 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -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 { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; import { t } from "@/lib/i18n/de"; -// Platzhalter-Anmeldeseite. Der Auth-Workstream (Workstream 3) ersetzt die -// Logik durch Authentik-OIDC bzw. lokalen Login. -export default function AnmeldenPage() { +// Anmeldeseite (öffentlich, in der Middleware-Allowlist). Bietet Authentik-OIDC +// (Platform-Admins) und lokalen Credentials-Login (Wehr-Konten). +export default async function AnmeldenPage() { + // Bereits angemeldete Nutzer nicht erneut anmelden lassen. + const session = await auth(); + if (session?.user) redirect("/"); + return (

{t("auth.anmelden")}

{t("auth.erforderlich")}

-
-
- - -
-
- - -
-
+ +

+ {t("auth.oderLokal")} +

+ +
); } diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..86c9f3d --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { handlers } from "@/auth"; + +export const { GET, POST } = handlers; diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts new file mode 100644 index 0000000..bfc4980 --- /dev/null +++ b/src/app/api/health/route.ts @@ -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" }); +} diff --git a/src/auth.config.ts b/src/auth.config.ts new file mode 100644 index 0000000..10e2e59 --- /dev/null +++ b/src/auth.config.ts @@ -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; diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..31b7e0a --- /dev/null +++ b/src/auth.ts @@ -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; + }, + }, +}); diff --git a/src/lib/auth/__tests__/password.test.ts b/src/lib/auth/__tests__/password.test.ts new file mode 100644 index 0000000..e0e28b4 --- /dev/null +++ b/src/lib/auth/__tests__/password.test.ts @@ -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); + }); +}); diff --git a/src/lib/auth/__tests__/rate-limit.test.ts b/src/lib/auth/__tests__/rate-limit.test.ts new file mode 100644 index 0000000..1cd1b92 --- /dev/null +++ b/src/lib/auth/__tests__/rate-limit.test.ts @@ -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); + }); +}); diff --git a/src/lib/auth/__tests__/roles.test.ts b/src/lib/auth/__tests__/roles.test.ts new file mode 100644 index 0000000..e18abf3 --- /dev/null +++ b/src/lib/auth/__tests__/roles.test.ts @@ -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); + }); + }); +}); diff --git a/src/lib/auth/guards.ts b/src/lib/auth/guards.ts new file mode 100644 index 0000000..676d8a0 --- /dev/null +++ b/src/lib/auth/guards.ts @@ -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 { + const s = await auth(); + if (!s?.user) redirect("/login"); + return s as AppSession; +} + +export async function requireRole(...allowed: Role[]): Promise { + const s = await requireSession(); + if (!allowed.includes(s.user.role)) forbidden(); // 403 + return s; +} + +export async function requirePlatformAdmin(): Promise { + 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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/src/lib/auth/password.ts b/src/lib/auth/password.ts new file mode 100644 index 0000000..cd93412 --- /dev/null +++ b/src/lib/auth/password.ts @@ -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 => + hash(pw, ARGON2_PARAMS); + +export const verifyPassword = (h: string, pw: string): Promise => + verify(h, pw, ARGON2_PARAMS); diff --git a/src/lib/auth/rate-limit.ts b/src/lib/auth/rate-limit.ts new file mode 100644 index 0000000..771f08a --- /dev/null +++ b/src/lib/auth/rate-limit.ts @@ -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 { + const since = new Date(Date.now() - RATE_LIMIT.windowMs); + const [row] = await db + .select({ count: sql`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 { + await db.insert(loginAttempts).values({ key, erfolg: result === "ok" }); +} diff --git a/src/lib/auth/roles.ts b/src/lib/auth/roles.ts new file mode 100644 index 0000000..49e62fb --- /dev/null +++ b/src/lib/auth/roles.ts @@ -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; + +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; +} diff --git a/src/lib/i18n/de.ts b/src/lib/i18n/de.ts index c5af830..230a272 100644 --- a/src/lib/i18n/de.ts +++ b/src/lib/i18n/de.ts @@ -11,6 +11,12 @@ export const de = { anmelden: "Anmelden", abmelden: "Abmelden", 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: { einsatzbereit: "einsatzbereit", diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..c8941fc --- /dev/null +++ b/src/middleware.ts @@ -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?)$).*)", + ], +}; diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts new file mode 100644 index 0000000..306d1a0 --- /dev/null +++ b/src/types/next-auth.d.ts @@ -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; + } +} diff --git a/tests/e2e/auth-gating.spec.ts b/tests/e2e/auth-gating.spec.ts new file mode 100644 index 0000000..1039add --- /dev/null +++ b/tests/e2e/auth-gating.spec.ts @@ -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; diff --git a/vitest.config.ts b/vitest.config.ts index cd82671..182ce02 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ test: { environment: "node", globals: true, + setupFiles: ["./vitest.setup.ts"], include: ["src/**/*.test.ts", "src/**/__tests__/**/*.test.ts"], }, }); diff --git a/vitest.setup.ts b/vitest.setup.ts new file mode 100644 index 0000000..17f5ee5 --- /dev/null +++ b/vitest.setup.ts @@ -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 = { + 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; +}