From d8c65d5121827b95ffe5334a6d4b7888c97674f3 Mon Sep 17 00:00:00 2001 From: gitadmin Date: Mon, 29 Jun 2026 18:39:46 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20SSO=20=E7=BB=9F=E4=B8=80=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=20+=20ThemeProvider=20=E4=B8=89=E6=A8=A1=E5=BC=8F=20+?= =?UTF-8?q?=20Card=20=E8=BE=B9=E6=A1=86=E7=BB=9F=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 安装 openid-client v5,新增 OIDC 登录/callback 端点 - ThemeProvider 支持 light/dark/auto 三种模式 - TopBar 主题切换改为下拉菜单 - Card 暗色边框统一为 slate-800 - middleware 登录页已登录自动跳转 + /api/auth/login 放行 - 登录页添加 SSO 按钮 + LDAP 回退 --- .gitignore | 1 + package-lock.json | 516 +++++++++++++++++++++ package.json | 1 + src/app/(auth)/login/page.tsx | 59 ++- src/app/api/auth/callback/route.ts | 130 ++++++ src/app/api/auth/login/oidc/route.ts | 81 ++++ src/components/layout/TopBar.tsx | 36 +- src/components/providers/ThemeProvider.tsx | 36 +- src/components/ui/index.tsx | 2 +- src/lib/oidc.ts | 47 ++ src/middleware.ts | 16 +- 11 files changed, 890 insertions(+), 35 deletions(-) create mode 100644 src/app/api/auth/callback/route.ts create mode 100644 src/app/api/auth/login/oidc/route.ts create mode 100644 src/lib/oidc.ts diff --git a/.gitignore b/.gitignore index 7a7ccde..eeb32e3 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ docs/ db-backups/ .playwright-mcp/ *.tsbuildinfo +.env.local diff --git a/package-lock.json b/package-lock.json index cc1ea03..1f6c215 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "mailparser": "^3.9.11", "next": "^15.1.0", "node-cron": "^4.5.0", + "openid-client": "^5.7.1", "puppeteer": "^23.0.0", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -39,6 +40,7 @@ "eslint": "^10.2.1", "postcss": "^8.4.49", "tailwindcss": "^4.0.0", + "tsc-alias": "^1.8.10", "tsx": "^4.19.0", "typescript": "^5.6.3" } @@ -1423,6 +1425,44 @@ "node": ">= 10" } }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", @@ -2043,12 +2083,36 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/ast-types": { "version": "0.13.4", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", @@ -2231,6 +2295,19 @@ "prebuild-install": "^7.1.1" } }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -2284,6 +2361,19 @@ "node": "18 || 20 || >=22" } }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -2407,6 +2497,44 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/chownr": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", @@ -2482,6 +2610,16 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || >=14" + } + }, "node_modules/cookie": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", @@ -2810,6 +2948,19 @@ "integrity": "sha512-XxtPuC3PGakY6PD7dG66/o8KwJ/LkH2/EKe19Dcw58w53dv4/vSQEkn/SzuyhHE2q4zPgCkxQBxus3VV4ql+Pg==", "license": "BSD-3-Clause" }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/docx": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/docx/-/docx-9.6.1.tgz", @@ -3327,6 +3478,36 @@ "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", "license": "MIT" }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -3341,6 +3522,16 @@ "dev": true, "license": "MIT" }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, "node_modules/fd-slicer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", @@ -3369,6 +3560,19 @@ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", "license": "MIT" }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -3507,6 +3711,27 @@ "node": ">=10.13.0" } }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -3752,6 +3977,19 @@ "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "license": "MIT" }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3784,6 +4022,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -3807,6 +4055,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4437,6 +4694,30 @@ "url": "https://opencollective.com/express" } }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, "node_modules/mimic-response": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", @@ -4498,6 +4779,20 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/mylas": { + "version": "2.1.14", + "resolved": "https://registry.npmjs.org/mylas/-/mylas-2.1.14.tgz", + "integrity": "sha512-BzQguy9W9NJgoVn2mRWzbFrFWWztGCcng2QI9+41frfk+Athwgx3qhqhvStz7ExeUUu7Kzw427sNzHpEZNINog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/raouldeheer" + } + }, "node_modules/nanoid": { "version": "5.1.9", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.9.tgz", @@ -4666,6 +4961,16 @@ "node": ">=6.0.0" } }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -4687,6 +4992,24 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/oidc-token-hash": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz", + "integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==", + "license": "MIT", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", @@ -4705,6 +5028,33 @@ "wrappy": "1" } }, + "node_modules/openid-client": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", + "integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==", + "license": "MIT", + "dependencies": { + "jose": "^4.15.9", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/openid-client/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -4905,6 +5255,16 @@ "node": ">=8" } }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/peberminta": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.10.0.tgz", @@ -4926,6 +5286,19 @@ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "license": "ISC" }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/pino": { "version": "10.3.1", "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", @@ -4963,6 +5336,19 @@ "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", "license": "MIT" }, + "node_modules/plimit-lit": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/plimit-lit/-/plimit-lit-1.6.1.tgz", + "integrity": "sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "queue-lit": "^1.5.1" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/postcss": { "version": "8.5.10", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", @@ -5189,6 +5575,37 @@ "node": ">=18" } }, + "node_modules/queue-lit": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/queue-lit/-/queue-lit-1.5.2.tgz", + "integrity": "sha512-tLc36IOPeMAubu8BkW8YDBV+WyIgKlYU7zUNs0J5Vk9skSZ4JfGlPOqplP0aHdfv7HL0B2Pg6nwiq60Qc6M2Hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/quick-format-unescaped": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", @@ -5289,6 +5706,19 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/real-require": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", @@ -5358,6 +5788,41 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -5551,6 +6016,16 @@ "simple-concat": "^1.0.0" } }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -5848,6 +6323,41 @@ "tlds": "bin.js" } }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tsc-alias": { + "version": "1.8.17", + "resolved": "https://registry.npmjs.org/tsc-alias/-/tsc-alias-1.8.17.tgz", + "integrity": "sha512-EIduCZHqbNwPm8BZYfq1aD7BQ697A4h6uSGMOFQfYGoQwfrYFTKwYfy9Bv42YxHkduVBcn9Zx0DkX111DKskyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.3", + "commander": "^9.0.0", + "get-tsconfig": "^4.10.0", + "globby": "^11.0.4", + "mylas": "^2.1.9", + "normalize-path": "^3.0.0", + "plimit-lit": "^1.2.6" + }, + "bin": { + "tsc-alias": "dist/bin/index.js" + }, + "engines": { + "node": ">=16.20.2" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -6147,6 +6657,12 @@ "node": ">=10" } }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/package.json b/package.json index ea8efd3..f378e41 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "mailparser": "^3.9.11", "next": "^15.1.0", "node-cron": "^4.5.0", + "openid-client": "^5.7.1", "puppeteer": "^23.0.0", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index d54c713..ca04323 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -6,6 +6,7 @@ export default function LoginPage() { const [password, setPassword] = useState('') const [error, setError] = useState('') const [loading, setLoading] = useState(false) + const [showLdapForm, setShowLdapForm] = useState(false) const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(''); setLoading(true) @@ -20,27 +21,53 @@ export default function LoginPage() { } catch { setError('网络错误') } finally { setLoading(false) } } + function handleSsoLogin() { + window.location.href = '/api/auth/login/oidc' + } + return (

IT 工单跟踪系统

{error &&
{error}
} -
-
- - setUsername(e.target.value)} placeholder="请输入用户名" - className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" required /> -
-
- - setPassword(e.target.value)} placeholder="请输入密码" - className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" required /> -
- -
+ + {!showLdapForm ? ( + <> + +

通过 SSO 统一身份认证

+

+ +

+ + ) : ( + <> +
+
+ + setUsername(e.target.value)} placeholder="请输入用户名" + className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" required /> +
+
+ + setPassword(e.target.value)} placeholder="请输入密码" + className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" required /> +
+ +
+

+ +

+ + )}
) diff --git a/src/app/api/auth/callback/route.ts b/src/app/api/auth/callback/route.ts new file mode 100644 index 0000000..81ab5c3 --- /dev/null +++ b/src/app/api/auth/callback/route.ts @@ -0,0 +1,130 @@ +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' +import { getOidcClient } from '@/lib/oidc' +import { createToken } from '@/lib/auth' +import { signSharedJwt, sharedCookieConfig } from '@/lib/jwt-shared' +import { getDb } from '@/lib/db' +import { getUserPermissions } from '@/lib/permissions' +import { ldapGetUserInfo } from '@/lib/ldap' + +function getBaseUrl(): string { + const redirectUri = process.env.OIDC_REDIRECT_URI || 'http://127.0.0.1:6176/api/auth/callback' + const url = new URL(redirectUri) + return `${url.protocol}//${url.host}` +} + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url) + const code = searchParams.get('code') + const state = searchParams.get('state') + const error = searchParams.get('error') + const baseUrl = getBaseUrl() + + const cookieStore = await cookies() + + // 1. 错误处理 + if (error) { + return NextResponse.redirect(new URL(`/login?error=${error}`, baseUrl)) + } + + // 2. 验证 state + const savedState = cookieStore.get('oidc_state')?.value + if (!savedState || savedState !== state) { + return NextResponse.redirect(new URL('/login?error=state_mismatch', baseUrl)) + } + + // 3. 取出 code_verifier + const codeVerifier = cookieStore.get('oidc_code_verifier')?.value + if (!codeVerifier) { + return NextResponse.redirect(new URL('/login?error=missing_verifier', baseUrl)) + } + + // 4. 验证 nonce + const savedNonce = cookieStore.get('oidc_nonce')?.value + + try { + // 5. 换取 token + const client = await getOidcClient() + const redirectUri = process.env.OIDC_REDIRECT_URI || 'http://127.0.0.1:6176/api/auth/callback' + const params = { code, state, iss: searchParams.get('iss') } + const checks = { + code_verifier: codeVerifier, + nonce: savedNonce, + state: savedState, + } + + const tokenSet = await client.callback(redirectUri, params, checks) + + // 6. 获取 userinfo + const userinfo = await client.userinfo(tokenSet.access_token!) + + // 7. 处理用户 + const username = (userinfo as any).preferred_username || userinfo.sub! + const displayName = userinfo.name || username + + const db = getDb() + + // 查找本地用户 + let user = db.prepare( + 'SELECT id, username, display_name, role FROM users WHERE username = ? AND is_active = 1' + ).get(username) as { id: number; username: string; display_name: string; role: string } | undefined + + if (!user) { + // 自动创建(viewer 角色) + const ldapInfo = await ldapGetUserInfo(username) + const ldapDisplayName = ldapInfo?.displayName || displayName + const email = ldapInfo?.email ?? null + db.prepare( + "INSERT OR IGNORE INTO users (username, display_name, email, role, is_active, created_at, updated_at) VALUES (?, ?, ?, 'viewer', 1, datetime('now', '+8 hours'), datetime('now', '+8 hours'))" + ).run(username, ldapDisplayName, email) + user = db.prepare( + 'SELECT id, username, display_name, role FROM users WHERE username = ? AND is_active = 1' + ).get(username) as { id: number; username: string; display_name: string; role: string } + } + + // 更新登录时间 + db.prepare("UPDATE users SET last_login_at = datetime('now', '+8 hours'), last_active_at = datetime('now', '+8 hours') WHERE id = ?").run(user!.id) + + // 8. 签发两个 cookie + const localToken = await createToken({ + id: user!.id, + username: user!.username, + display_name: user!.display_name, + role: user!.role, + permissions: getUserPermissions(user!.role), + }) + const sharedToken = signSharedJwt({ username, displayName }) + + const response = NextResponse.redirect(new URL('/', baseUrl)) + response.cookies.set('session_issue', localToken, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 7 * 24 * 60 * 60, + path: '/', + }) + response.cookies.set(sharedCookieConfig().name, sharedToken, sharedCookieConfig()) + + // 9. 存储 id_token 用于登出 + if (tokenSet.id_token) { + response.cookies.set('oidc_id_token', tokenSet.id_token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 86400, + path: '/', + }) + } + + // 10. 清理 OIDC 临时 cookie + response.cookies.delete('oidc_state') + response.cookies.delete('oidc_nonce') + response.cookies.delete('oidc_code_verifier') + + return response + } catch (e) { + const errorMsg = e instanceof Error ? e.message : String(e) + console.error('OIDC callback error:', errorMsg) + return NextResponse.redirect(new URL(`/login?error=callback_error&detail=${encodeURIComponent(errorMsg)}`, baseUrl)) + } +} diff --git a/src/app/api/auth/login/oidc/route.ts b/src/app/api/auth/login/oidc/route.ts new file mode 100644 index 0000000..b1713ac --- /dev/null +++ b/src/app/api/auth/login/oidc/route.ts @@ -0,0 +1,81 @@ +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' +import { getOidcClient, generatePKCE, generateState, generateNonce } from '@/lib/oidc' +import { verifySharedJwt } from '@/lib/jwt-shared' + +export async function GET(request: Request) { + const cookieStore = await cookies() + const existingSession = cookieStore.get('tlyq_session')?.value + const url = new URL(request.url) + const switchUser = url.searchParams.get('switch') === '1' + + // 检查是否已有登录用户 + if (existingSession && !switchUser) { + const existing = verifySharedJwt(existingSession) + if (existing) { + return NextResponse.json({ + conflict: true, + currentUser: existing.username, + displayName: existing.displayName, + }) + } + } + + // 预检 Authelia 健康状态 + const autheliaUrl = process.env.AUTHELIA_URL || 'https://sso.tlyq.ai' + try { + const healthRes = await fetch(`${autheliaUrl}/api/health`, { + signal: AbortSignal.timeout(5000), + }) + if (!healthRes.ok) { + return NextResponse.json({ fallback: 'ldap', error: 'Authelia 不可用' }, { status: 503 }) + } + } catch { + return NextResponse.json({ fallback: 'ldap', error: 'Authelia 不可达' }, { status: 503 }) + } + + // 生成 PKCE 参数 + const { codeVerifier, codeChallenge } = generatePKCE() + const state = generateState() + const nonce = generateNonce() + + // 构建授权 URL + const client = await getOidcClient() + const authorizationUrl = client.authorizationUrl({ + scope: 'openid profile email', + state, + nonce, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + ...(switchUser && { prompt: 'login' }), + }) + + // 存储到 httpOnly cookie(5 分钟过期) + const response = NextResponse.redirect(authorizationUrl) + + response.cookies.set('oidc_code_verifier', codeVerifier, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 300, + path: '/', + }) + + response.cookies.set('oidc_state', state, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 300, + path: '/', + }) + + response.cookies.set('oidc_nonce', nonce, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 300, + path: '/', + }) + + return response +} diff --git a/src/components/layout/TopBar.tsx b/src/components/layout/TopBar.tsx index 6855c33..65aef5a 100644 --- a/src/components/layout/TopBar.tsx +++ b/src/components/layout/TopBar.tsx @@ -2,21 +2,53 @@ import { useTheme } from '@/components/providers/ThemeProvider' import { Sun, Moon, LogOut, User } from 'lucide-react' import { useRouter } from 'next/navigation' +import { useState, useRef, useEffect } from 'react' + +type Theme = 'light' | 'dark' | 'auto' interface TopBarProps { user: { username: string; display_name: string; role: string } | null } export default function TopBar({ user }: TopBarProps) { - const { theme, toggleTheme } = useTheme() + const { theme, setTheme } = useTheme() const router = useRouter() + const [open, setOpen] = useState(false) + const ref = useRef(null) + + useEffect(() => { + const handler = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, []) + const handleLogout = async () => { await fetch('/api/auth/logout', { method: 'POST' }) router.push('/login'); router.refresh() } + + const icons: Record = { light: , dark: , auto: } + const labels: Record = { light: '浅色', dark: '深色', auto: '自动' } + return (
- +
+ + {open && ( +
+ {(['light', 'dark', 'auto'] as Theme[]).map(t => ( + + ))} +
+ )} +
{user && (
{user.display_name}
)}
diff --git a/src/components/providers/ThemeProvider.tsx b/src/components/providers/ThemeProvider.tsx index 464c235..7b8b192 100644 --- a/src/components/providers/ThemeProvider.tsx +++ b/src/components/providers/ThemeProvider.tsx @@ -1,25 +1,35 @@ 'use client' import { createContext, useContext, useEffect, useState, ReactNode } from 'react' -type Theme = 'light' | 'dark' -interface ThemeContextType { theme: Theme; toggleTheme: () => void } -const ThemeContext = createContext({ theme: 'dark', toggleTheme: () => {} }) +type Theme = 'light' | 'dark' | 'auto' +interface ThemeContextType { theme: Theme; setTheme: (t: Theme) => void } +const ThemeContext = createContext({ theme: 'auto', setTheme: () => {} }) export function useTheme() { return useContext(ThemeContext) } +function applyTheme(t: Theme) { + const root = document.documentElement + root.classList.remove('light', 'dark') + if (t === 'auto') { + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches + root.classList.add(prefersDark ? 'dark' : 'light') + } else { + root.classList.add(t) + } +} + export function ThemeProvider({ children }: { children: ReactNode }) { - const [theme, setTheme] = useState('dark') + const [theme, setThemeState] = useState('auto') useEffect(() => { const stored = localStorage.getItem('theme') as Theme | null - if (stored) { setTheme(stored); document.documentElement.classList.toggle('dark', stored === 'dark') } - else { - const d = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' - setTheme(d); document.documentElement.classList.toggle('dark', d === 'dark') - } + const initial = stored || 'auto' + setThemeState(initial) + applyTheme(initial) }, []) - const toggleTheme = () => { - const n = theme === 'dark' ? 'light' : 'dark' - setTheme(n); localStorage.setItem('theme', n); document.documentElement.classList.toggle('dark', n === 'dark') + const setTheme = (t: Theme) => { + setThemeState(t) + localStorage.setItem('theme', t) + applyTheme(t) } - return {children} + return {children} } diff --git a/src/components/ui/index.tsx b/src/components/ui/index.tsx index cac724c..98de0f8 100644 --- a/src/components/ui/index.tsx +++ b/src/components/ui/index.tsx @@ -82,7 +82,7 @@ export function Badge({ variant = 'default', children }: BadgeProps) { interface CardProps { children: ReactNode; className?: string } export function Card({ children, className = '' }: CardProps) { - return (
{children}
) + return (
{children}
) } interface ToastProps { message: string; type?: 'success' | 'error' | 'info'; onClose: () => void } diff --git a/src/lib/oidc.ts b/src/lib/oidc.ts new file mode 100644 index 0000000..340350d --- /dev/null +++ b/src/lib/oidc.ts @@ -0,0 +1,47 @@ +import { Issuer } from 'openid-client' +import crypto from 'crypto' + +const AUTHELIA_URL = process.env.AUTHELIA_URL || 'https://sso.tlyq.ai' +const OIDC_CLIENT_ID = process.env.OIDC_CLIENT_ID || 'issue-oidc' +const OIDC_CLIENT_SECRET = process.env.OIDC_CLIENT_SECRET || '' +const OIDC_REDIRECT_URI = process.env.OIDC_REDIRECT_URI || 'https://issue.tlyq.ai/api/auth/callback' + +let oidcClient: any = null +let lastDiscovery = 0 +const DISCOVERY_TTL = 3600000 // 1 小时 + +export async function getOidcClient() { + const now = Date.now() + if (oidcClient && (now - lastDiscovery) < DISCOVERY_TTL) { + return oidcClient + } + + try { + const issuer = await Issuer.discover(AUTHELIA_URL) + oidcClient = new issuer.Client({ + client_id: OIDC_CLIENT_ID, + client_secret: OIDC_CLIENT_SECRET, + redirect_uris: [OIDC_REDIRECT_URI], + response_types: ['code'], + }) + lastDiscovery = now + return oidcClient + } catch (error) { + console.error('OIDC discovery 失败:', error) + throw error + } +} + +export function generatePKCE() { + const codeVerifier = crypto.randomBytes(32).toString('base64url') + const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url') + return { codeVerifier, codeChallenge } +} + +export function generateState() { + return crypto.randomBytes(32).toString('base64url') +} + +export function generateNonce() { + return crypto.randomBytes(32).toString('base64url') +} diff --git a/src/middleware.ts b/src/middleware.ts index dee7367..83e4383 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -36,9 +36,19 @@ function buildLoginRedirect(request: NextRequest) { export function middleware(request: NextRequest) { const { pathname } = request.nextUrl - // 登录/退出路径 + 内部 API 放行(自有 key 认证) - if (pathname.startsWith('/login') || pathname === '/' || - pathname === '/api/auth/login' || pathname === '/api/auth/logout' || + // 登录页:已登录用户自动跳转首页 + if (pathname.startsWith('/login')) { + const token = request.cookies.get('tlyq_session')?.value || request.cookies.get('session_issue')?.value + const payload = token ? decodeJwtPayload(token) : null + if (isValidPayload(payload)) { + return NextResponse.redirect(new URL('/dashboard', request.url)) + } + return NextResponse.next() + } + + // 首页/API 路径放行 + if (pathname === '/' || + pathname.startsWith('/api/auth/login') || pathname.startsWith('/api/auth/callback') || pathname === '/api/auth/logout' || pathname.startsWith('/api/internal/')) { return NextResponse.next() }