From e639425885bdc5b1655aac9a8e2e17fb1d4494f1 Mon Sep 17 00:00:00 2001 From: aiyimickey <39365912+aiyimickey@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:00:23 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20#26=20=E8=B7=A8=E7=AB=99=E7=82=B9=20JWT?= =?UTF-8?q?=20role=20=E4=BF=AE=E5=A4=8D=20=E2=80=94=20logout=20=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=20+=20=E5=AE=A1=E8=AE=A1=E6=97=A5=E5=BF=97=20+=20?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 16 +- package-lock.json | 445 +++++++++++++++++- package.json | 2 + "src/app/api/admin/create-user/route.ts\"\"" | 151 ------ "src/app/api/admin/roles/route.ts\"\"" | 32 -- "src/app/api/admin/sync-emails/route.ts\"\"" | 48 -- "src/app/api/admin/user-roles/route.ts\"\"" | 104 ---- "src/app/api/admin/users/route.ts\"\"" | 132 ------ src/app/api/auth/callback/route.ts | 12 +- "src/app/api/auth/callback/route.ts\"\"" | 27 -- .../api/auth/change-password/route.ts\"\"" | 56 --- "src/app/api/auth/login/oidc/route.ts\"\"" | 13 - src/app/api/auth/login/route.ts | 18 +- "src/app/api/auth/login/route.ts\"\"" | 32 -- src/app/api/auth/logout/route.ts | 2 +- "src/app/api/auth/logout/route.ts\"\"" | 30 -- "src/app/api/auth/me/route.ts\"\"" | 83 ---- .../app/api/auth/setup-password/route.ts\"\"" | 54 --- "src/app/api/health/route.ts\"\"" | 5 - src/lib/audit.ts | 17 + src/lib/db.ts | 23 + src/lib/ldap.ts | 19 +- src/lib/sync-user.ts | 34 ++ 23 files changed, 576 insertions(+), 779 deletions(-) delete mode 100644 "src/app/api/admin/create-user/route.ts\"\"" delete mode 100644 "src/app/api/admin/roles/route.ts\"\"" delete mode 100644 "src/app/api/admin/sync-emails/route.ts\"\"" delete mode 100644 "src/app/api/admin/user-roles/route.ts\"\"" delete mode 100644 "src/app/api/admin/users/route.ts\"\"" delete mode 100644 "src/app/api/auth/callback/route.ts\"\"" delete mode 100644 "src/app/api/auth/change-password/route.ts\"\"" delete mode 100644 "src/app/api/auth/login/oidc/route.ts\"\"" delete mode 100644 "src/app/api/auth/login/route.ts\"\"" delete mode 100644 "src/app/api/auth/logout/route.ts\"\"" delete mode 100644 "src/app/api/auth/me/route.ts\"\"" delete mode 100644 "src/app/api/auth/setup-password/route.ts\"\"" delete mode 100644 "src/app/api/health/route.ts\"\"" create mode 100644 src/lib/audit.ts create mode 100644 src/lib/db.ts create mode 100644 src/lib/sync-user.ts diff --git a/CLAUDE.md b/CLAUDE.md index 6202743..58a5d10 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,7 +41,11 @@ npm run build # 生产构建 | `src/app/login/page.tsx` | 登录页(LLDAP 认证) | | `src/app/profile/page.tsx` | 个人信息页(账户信息 + 修改密码) | | `src/app/admin/create-user/page.tsx` | 用户管理页(创建/删除/角色管理,仅 admin 可见) | -| `src/app/api/auth/login/route.ts` | 登录 API(OA 仅 LLDAP 认证,无本地 DB) | +| `src/app/api/auth/login/route.ts` | 登录 API(LDAP 认证 + 审计日志 + 跨站点角色同步) | +| `src/app/api/auth/callback/route.ts` | OIDC callback(handleOidcCallback + 跨站点角色同步) | +| `src/lib/db.ts` | **新增** SQLite 数据库(审计日志专用) | +| `src/lib/audit.ts` | **新增** 审计日志写入封装 | +| `src/lib/sync-user.ts` | **新增** 跨站点用户角色同步函数(syncUserToAllSites) | | `src/app/api/auth/logout/route.ts` | 退出 API(清除 tlyq_session) | | `src/app/api/auth/change-password/route.ts` | 修改密码(docker exec 调 lldap_set_password) | | `src/app/api/admin/create-user/route.ts` | 创建用户(SQLite 写 LLDAP + 自动同步站点 + 角色设置) | @@ -83,6 +87,11 @@ OA 本身**不存储用户数据**(无本地 users 表),纯 LLDAP 认证 | `OIDC_CLIENT_SECRET` | 本地生成的哈希值 | 服务器生成的哈希值 | | `OIDC_REDIRECT_URI` | `http://localhost:6179/api/auth/callback` | `https://oa.tlyq.ai/api/auth/callback` | | `NODE_TLS_REJECT_UNAUTHORIZED` | 不需要 | `0`(Authelia 使用自签名证书) | +| `INTERNAL_API_KEY` | 各站点相同值 | 由 deploy-ai.sh 自动生成并注入(所有站点共用) | +| `MONITOR_INTERNAL_URL` | `http://localhost:6181` | `http://monitor-ai:3000`(跨站点同步目标) | +| `ASSETS_INTERNAL_URL` | `http://localhost:6177` | `http://assets-ai:3000`(跨站点同步目标) | +| `ISSUE_INTERNAL_URL` | `http://localhost:6176` | `http://issue-ai:3000`(跨站点同步目标) | +| `DATABASE_PATH` | `./data/oa.db` | `/app/data/oa.db`(审计日志 SQLite) | ### `.env` 示例 @@ -98,6 +107,11 @@ AUTHELIA_URL=https://sso.tlyq.ai OIDC_CLIENT_ID=oa-oidc OIDC_CLIENT_SECRET=<本地生成的哈希值> OIDC_REDIRECT_URI=http://localhost:6179/api/auth/callback +DATABASE_PATH=./data/oa.db +INTERNAL_API_KEY=dev-internal-key-change-in-production +MONITOR_INTERNAL_URL=http://localhost:6181 +ASSETS_INTERNAL_URL=http://localhost:6177 +ISSUE_INTERNAL_URL=http://localhost:6176 ``` --- diff --git a/package-lock.json b/package-lock.json index 3e32055..dba573d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "bcryptjs": "^3.0.3", + "better-sqlite3": "^12.11.1", "ldapts": "^6.0.0", "next": "^15.0.0", "openid-client": "^5.7.1", @@ -19,6 +20,7 @@ "devDependencies": { "@tailwindcss/postcss": "^4.3.1", "@types/bcryptjs": "^2.4.6", + "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.0.0", "@types/react": "^19.0.0", "autoprefixer": "^10.5.2", @@ -1074,6 +1076,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/node": { "version": "22.19.18", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.18.tgz", @@ -1145,6 +1157,26 @@ "postcss": "^8.1.0" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "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/baseline-browser-mapping": { "version": "2.10.40", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", @@ -1167,6 +1199,40 @@ "bcrypt": "bin/bcrypt" } }, + "node_modules/better-sqlite3": { + "version": "12.11.1", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz", + "integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/browserslist": { "version": "4.28.4", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", @@ -1201,6 +1267,30 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "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": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001799", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", @@ -1221,6 +1311,12 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -1251,11 +1347,34 @@ } } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -1268,6 +1387,15 @@ "dev": true, "license": "ISC" }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.21.6", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", @@ -1292,12 +1420,27 @@ "node": ">=6" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/fast-sha256": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", "license": "Unlicense" }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -1312,6 +1455,18 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -1319,6 +1474,38 @@ "dev": true, "license": "ISC" }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "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": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -1651,6 +1838,33 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1675,6 +1889,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "node_modules/next": { "version": "15.5.18", "resolved": "https://registry.npmjs.org/next/-/next-15.5.18.tgz", @@ -1755,6 +1975,18 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-abi": { + "version": "3.94.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", + "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-releases": { "version": "2.0.50", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", @@ -1783,6 +2015,15 @@ "node": "^10.13.0 || >=12.0.0" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/openid-client": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", @@ -1846,6 +2087,58 @@ "dev": true, "license": "MIT" }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, "node_modules/react": { "version": "19.2.6", "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", @@ -1867,6 +2160,20 @@ "react": "^19.2.6" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/resend": { "version": "6.12.3", "resolved": "https://registry.npmjs.org/resend/-/resend-6.12.3.tgz", @@ -1888,6 +2195,26 @@ } } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "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/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -1905,7 +2232,6 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", "license": "ISC", - "optional": true, "bin": { "semver": "bin/semver.js" }, @@ -1958,6 +2284,51 @@ "@img/sharp-win32-x64": "0.34.5" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "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/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "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": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1983,6 +2354,24 @@ "integrity": "sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==", "license": "ISC" }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -2036,12 +2425,52 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -2093,6 +2522,12 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/uuid": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", @@ -2107,6 +2542,12 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", diff --git a/package.json b/package.json index eefa090..8da4a52 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ }, "dependencies": { "bcryptjs": "^3.0.3", + "better-sqlite3": "^12.11.1", "ldapts": "^6.0.0", "next": "^15.0.0", "openid-client": "^5.7.1", @@ -19,6 +20,7 @@ "devDependencies": { "@tailwindcss/postcss": "^4.3.1", "@types/bcryptjs": "^2.4.6", + "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.0.0", "@types/react": "^19.0.0", "autoprefixer": "^10.5.2", diff --git "a/src/app/api/admin/create-user/route.ts\"\"" "b/src/app/api/admin/create-user/route.ts\"\"" deleted file mode 100644 index 76ad81f..0000000 --- "a/src/app/api/admin/create-user/route.ts\"\"" +++ /dev/null @@ -1,151 +0,0 @@ -import { NextResponse } from 'next/server' -import { cookies } from 'next/headers' -import { exec } from 'child_process' -import { promisify } from 'util' -import { verifySharedJwt } from '@/lib/jwt' -import { isLldapAdmin } from '@/lib/ldap' -import { sendSetupLinkEmail } from '@/lib/email' -import { signSetupToken } from '@/lib/setup-token' - -const execAsync = promisify(exec) - -const INTERNAL_KEY = 'oa-internal-key-tlyq-2026' - -function generatePassword(): string { - const upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ' - const lower = 'abcdefghjkmnpqrstuvwxyz' - const digits = '23456789' - const special = '!@#$%&*' - const all = upper + lower + digits + special - const crypto = globalThis.crypto - const pick = (s: string) => s[crypto.getRandomValues(new Uint32Array(1))[0] % s.length] - // 确保每种类型至少一个,其余随机填充到 12 位 - let pwd = pick(upper) + pick(lower) + pick(digits) + pick(special) - for (let i = 4; i < 12; i++) pwd += pick(all) - // 打乱顺序 - return pwd.split('').sort(() => crypto.getRandomValues(new Uint32Array(1))[0] - 0x80000000).join('') -} - -async function fetchRoles(siteUrl: string): Promise { - try { - const res = await fetch(`${siteUrl}/api/internal/roles`, { - headers: { 'x-internal-key': INTERNAL_KEY }, - signal: AbortSignal.timeout(5000), - }) - const data = await res.json() - return (data.roles || []).map((r: { name: string }) => r.name) - } catch { - return [] - } -} - -async function syncToSite(siteUrl: string, username: string, password: string): Promise { - try { - const res = await fetch(`${siteUrl}/api/auth/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username, password }), - signal: AbortSignal.timeout(10000), - }) - return res.ok - } catch { - return false - } -} - -// 直接更新站点 SQLite 数据库中的用户角色 -function setRoleSQL(dbPath: string, username: string, role: string): string { - return `sqlite3 "${dbPath}" "UPDATE users SET role = '${role}', updated_at = datetime('now', '+8 hours') WHERE username = '${username}';"` -} - -export async function POST(request: Request) { - try { - const cookieStore = await cookies() - const token = cookieStore.get('tlyq_session')?.value - if (!token) return NextResponse.json({ error: '未登录' }, { status: 401 }) - const session = verifySharedJwt(token) - if (!session || !(await isLldapAdmin(session.username))) { - return NextResponse.json({ error: '仅管理员可创建用户' }, { status: 403 }) - } - - const { username, displayName, assetsRole, issueRole, email } = await request.json() - if (!username) return NextResponse.json({ error: '用户名不能为空' }, { status: 400 }) - if (!/^[a-z][a-z0-9_.@-]*$/i.test(username)) return NextResponse.json({ error: '用户名格式不合法' }, { status: 400 }) - - const password = generatePassword() - - // 从各站点实时获取可用角色列表 - const [assetsRoles, issueRoles] = await Promise.all([ - fetchRoles('http://localhost:6177'), - fetchRoles('http://localhost:6176'), - ]) - - const ar = (assetsRole && assetsRoles.includes(assetsRole)) ? assetsRole : 'viewer' - const ir = (issueRole && issueRoles.includes(issueRole)) ? issueRole : 'viewer' - - const safeName = (displayName || username).replace(/'/g, "'\\''") - const safeUser = username.replace(/'/g, "'\\''") - const lldapEmail = email || '' - const d = new Date() - const now = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}` - const userUuid = crypto.randomUUID() - - // 1. LLDAP SQLite 插入用户 - const insertSQL = `INSERT OR IGNORE INTO users (user_id, email, display_name, creation_date, uuid, lowercase_email, modified_date, password_modified_date) VALUES ('${username}', '${lldapEmail}', '${safeName}', '${now}', '${userUuid}', LOWER('${lldapEmail}'), '${now}', '${now}');` - await execAsync(`docker exec lldap /bin/sh -c "cat > /tmp/iu.sql <<'EOSQL'\n${insertSQL}\nEOSQL\nsqlite3 /data/users.db < /tmp/iu.sql"`, { timeout: 5000 }) - - // 2. 从 LLDAP 容器动态获取 admin 密码(不硬编码,admin 改密码后无需改 OA 配置) - const { stdout: adminPassOut } = await execAsync('docker exec lldap printenv LLDAP_ADMIN_PASSWORD', { timeout: 3000 }) - const adminPass = (adminPassOut.trim() || 'admin123').replace(/'/g, "'\\''") - - // 3. LLDAP 设置密码 —— 通过 base64 传输避免 shell 特殊字符问题 - const b64Pass = Buffer.from(password).toString('base64') - await execAsync(`docker exec lldap /bin/sh -c "echo '${b64Pass}' | base64 -d > /tmp/userpwd.txt"`, { timeout: 3000 }) - const pwdCmd = `LLDAP_USER_PASSWORD=$(cat /tmp/userpwd.txt) ./lldap_set_password --base-url http://localhost:17170 --admin-username admin --admin-password '${adminPass}' --username '${safeUser}'` - await execAsync(`docker exec lldap /bin/sh -c '${pwdCmd}'`, { timeout: 10000 }) - - // 3. 自动登录各站点触发用户同步 - const [assetsOk, issueOk] = await Promise.all([ - syncToSite('http://localhost:6177', username, password), - syncToSite('http://localhost:6176', username, password), - ]) - - // 4. 直接更新各站点 SQLite 的角色(覆盖 viewer 默认值) - const assetsDb = process.env.ASSETS_DB_PATH || '/Users/niuniu/programs/docker/assets-ai/data/assets.db' - const issueDb = process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db' - const roleResults = { assets: false, issue: false } - if (assetsOk) { - try { await execAsync(setRoleSQL(assetsDb, username, ar), { timeout: 3000 }); roleResults.assets = true } catch {} - } - if (issueOk) { - try { await execAsync(setRoleSQL(issueDb, username, ir), { timeout: 3000 }); roleResults.issue = true } catch {} - } - - // 5. 如果提供了邮箱,发送密码设置链接(不再在邮件中发送明文密码) - let emailSent = false - if (email && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { - try { - const setupToken = signSetupToken(username) - const setupUrl = `https://oa.tlyq.ai/setup-password?token=${setupToken}` - await sendSetupLinkEmail(email, username, setupUrl, displayName || username) - emailSent = true - } catch (e) { - console.error('发送邮件失败:', e) - } - } - - return NextResponse.json({ - success: true, - password: emailSent ? undefined : password, - synced: { assets: assetsOk, issue: issueOk }, - roles: { assets: ar, issue: ir, applied: roleResults }, - emailSent, - message: emailSent - ? `用户已创建,密码设置链接已发送至 ${email}` - : '用户已创建并同步至所有站点', - }) - } catch (e) { - const msg = e instanceof Error ? e.message : '创建失败' - return NextResponse.json({ error: msg }, { status: 500 }) - } -} diff --git "a/src/app/api/admin/roles/route.ts\"\"" "b/src/app/api/admin/roles/route.ts\"\"" deleted file mode 100644 index 6061f7b..0000000 --- "a/src/app/api/admin/roles/route.ts\"\"" +++ /dev/null @@ -1,32 +0,0 @@ -import { NextResponse } from 'next/server' -import { cookies } from 'next/headers' -import { verifySharedJwt } from '@/lib/jwt' -import { isLldapAdmin } from '@/lib/ldap' - -const INTERNAL_KEY = 'oa-internal-key-tlyq-2026' - -async function fetchRoles(url: string): Promise<{ name: string; display_name: string }[]> { - try { - const res = await fetch(`${url}/api/internal/roles`, { - headers: { 'x-internal-key': INTERNAL_KEY }, - signal: AbortSignal.timeout(5000), - }) - const data = await res.json() - return data.roles || [] - } catch { return [] } -} - -export async function GET() { - const cookieStore = await cookies() - const token = cookieStore.get('tlyq_session')?.value - if (!token) return NextResponse.json({ error: '未登录' }, { status: 401 }) - const session = verifySharedJwt(token) - if (!session || !(await isLldapAdmin(session.username))) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - - const [assetsRoles, issueRoles] = await Promise.all([ - fetchRoles('http://localhost:6177'), - fetchRoles('http://localhost:6176'), - ]) - - return NextResponse.json({ assets: assetsRoles, issue: issueRoles }) -} diff --git "a/src/app/api/admin/sync-emails/route.ts\"\"" "b/src/app/api/admin/sync-emails/route.ts\"\"" deleted file mode 100644 index eb7cab9..0000000 --- "a/src/app/api/admin/sync-emails/route.ts\"\"" +++ /dev/null @@ -1,48 +0,0 @@ -import { NextResponse } from 'next/server' -import { cookies } from 'next/headers' -import { exec } from 'child_process' -import { promisify } from 'util' -import { verifySharedJwt } from '@/lib/jwt' -import { isLldapAdmin } from '@/lib/ldap' - -const execAsync = promisify(exec) -const ASSETS_DB = process.env.ASSETS_DB_PATH || '/Users/niuniu/programs/docker/assets-ai/data/assets.db' -const ISSUE_DB = process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db' - -export async function POST() { - try { - const cookieStore = await cookies() - const token = cookieStore.get('tlyq_session')?.value - if (!token) return NextResponse.json({ error: '未登录' }, { status: 401 }) - const session = verifySharedJwt(token) - if (!session || !(await isLldapAdmin(session.username))) { - return NextResponse.json({ error: '仅管理员可操作' }, { status: 403 }) - } - - const { stdout } = await execAsync( - `docker exec lldap sqlite3 /data/users.db "SELECT user_id, email FROM users WHERE email != '';"`, - { timeout: 5000 } - ) - const lines = stdout.trim().split('\n').filter(Boolean) - let synced = 0 - - for (const line of lines) { - const [user, mail] = line.split('|') - const su = user.replace(/'/g, "''") - const sm = (mail || '').replace(/'/g, "''") - for (const db of [ASSETS_DB, ISSUE_DB]) { - try { - await execAsync( - `sqlite3 "${db}" "UPDATE users SET email = '${sm}', updated_at = datetime('now', '+8 hours') WHERE username = '${su}';"`, - { timeout: 3000 } - ) - } catch {} - } - synced++ - } - - return NextResponse.json({ success: true, synced }) - } catch (e) { - return NextResponse.json({ error: '同步失败' }, { status: 500 }) - } -} diff --git "a/src/app/api/admin/user-roles/route.ts\"\"" "b/src/app/api/admin/user-roles/route.ts\"\"" deleted file mode 100644 index 3b14859..0000000 --- "a/src/app/api/admin/user-roles/route.ts\"\"" +++ /dev/null @@ -1,104 +0,0 @@ -import { NextResponse } from 'next/server' -import { cookies } from 'next/headers' -import { exec } from 'child_process' -import { promisify } from 'util' -import { verifySharedJwt } from '@/lib/jwt' -import { isLldapAdmin } from '@/lib/ldap' - -const execAsync = promisify(exec) -const INTERNAL_KEY = 'oa-internal-key-tlyq-2026' - -async function fetchRoles(siteUrl: string): Promise { - try { - const res = await fetch(`${siteUrl}/api/internal/roles`, { - headers: { 'x-internal-key': INTERNAL_KEY }, - signal: AbortSignal.timeout(5000), - }) - const data = await res.json() - return (data.roles || []).map((r: { name: string }) => r.name) - } catch { return [] } -} - -function queryDb(dbPath: string, sql: string): Promise { - return execAsync(`sqlite3 "${dbPath}" "${sql.replace(/"/g, '\\"')}"`, { timeout: 3000 }).then(r => r.stdout).catch(() => '') -} - -async function getSiteUsers(dbPath: string, roles: string[]): Promise<{ username: string; display_name: string; role: string }[]> { - const out = await queryDb(dbPath, 'SELECT username, display_name, role FROM users WHERE is_active=1 ORDER BY username;') - return out.trim().split('\n').filter(Boolean).map(line => { - const [username, display_name, role] = line.split('|') - return { username, display_name: display_name || username, role: roles.includes(role) ? role : 'viewer' } - }) -} - -async function checkAdmin() { - const cookieStore = await cookies() - const token = cookieStore.get('tlyq_session')?.value - if (!token) return false - const session = verifySharedJwt(token) - return session ? isLldapAdmin(session.username) : false -} - -// GET — 列出各站点用户及其角色 -export async function GET() { - if (!(await checkAdmin())) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - - try { - const [assetsRoles, issueRoles] = await Promise.all([ - fetchRoles('http://localhost:6177'), - fetchRoles('http://localhost:6176'), - ]) - - const [assetsUsers, issueUsers] = await Promise.all([ - getSiteUsers(process.env.ASSETS_DB_PATH || '/Users/niuniu/programs/docker/assets-ai/data/assets.db', assetsRoles), - getSiteUsers(process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db', issueRoles), - ]) - - // 从 LLDAP 获取所有用户邮箱 - let emails: Record = {} - try { - const { stdout } = await execAsync( - `docker exec lldap /bin/sh -c "echo 'SELECT user_id, email FROM users;' | sqlite3 /data/users.db"`, - { timeout: 3000 } - ) - stdout.trim().split('\n').filter(Boolean).forEach(line => { - const [uid, e] = line.split('|') - emails[uid] = e || '' - }) - } catch {} - - return NextResponse.json({ - assetsRoles, - issueRoles, - users: { assets: assetsUsers, issue: issueUsers }, - emails, - }) - } catch (e) { - return NextResponse.json({ error: '查询失败' }, { status: 500 }) - } -} - -// PUT — 更新用户角色 -export async function PUT(request: Request) { - if (!(await checkAdmin())) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - - try { - const { username, site, role } = await request.json() - if (!username || !site || !role) return NextResponse.json({ error: '参数不完整' }, { status: 400 }) - if (username === 'admin' || username === 'localadmin') return NextResponse.json({ error: '不能修改系统保留用户角色' }, { status: 400 }) - - const dbPath = site === 'assets' - ? (process.env.ASSETS_DB_PATH || '/Users/niuniu/programs/docker/assets-ai/data/assets.db') - : (process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db') - - // 验证角色合法性 - const roles = await fetchRoles(`http://localhost:${site === 'assets' ? 6177 : 6176}`) - if (!roles.includes(role)) return NextResponse.json({ error: '无效的角色' }, { status: 400 }) - - await execAsync(`sqlite3 "${dbPath}" "UPDATE users SET role='${role}', updated_at=datetime('now', '+8 hours') WHERE username='${username}';"`, { timeout: 3000 }) - - return NextResponse.json({ success: true }) - } catch (e) { - return NextResponse.json({ error: '更新失败' }, { status: 500 }) - } -} diff --git "a/src/app/api/admin/users/route.ts\"\"" "b/src/app/api/admin/users/route.ts\"\"" deleted file mode 100644 index 913b070..0000000 --- "a/src/app/api/admin/users/route.ts\"\"" +++ /dev/null @@ -1,132 +0,0 @@ -import { NextResponse } from 'next/server' -import { cookies } from 'next/headers' -import { exec } from 'child_process' -import { promisify } from 'util' -import { verifySharedJwt } from '@/lib/jwt' -import { isLldapAdmin } from '@/lib/ldap' - -const execAsync = promisify(exec) - -function checkAdmin() { - return async () => { - const cookieStore = await cookies() - const token = cookieStore.get('tlyq_session')?.value - if (!token) return false - const session = verifySharedJwt(token) - return session ? isLldapAdmin(session.username) : false - } -} - -// GET — 列出 LLDAP 中所有用户 -export async function GET() { - const isAdmin = await checkAdmin()() - if (!isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - - try { - const { stdout } = await execAsync( - `docker exec lldap /bin/sh -c "echo 'SELECT user_id, email, display_name, creation_date FROM users ORDER BY creation_date DESC;' | sqlite3 /data/users.db"`, - { timeout: 5000 } - ) - const users = stdout.trim().split('\n').filter(Boolean).map(line => { - const [user_id, email, display_name, creation_date] = line.split('|') - return { username: user_id, email, displayName: display_name || user_id, createdAt: creation_date } - }) - return NextResponse.json({ users }) - } catch (e) { - return NextResponse.json({ error: '查询失败' }, { status: 500 }) - } -} - -// DELETE — 删除用户(LLDAP + 各站点) -export async function DELETE(request: Request) { - const isAdmin = await checkAdmin()() - if (!isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - - try { - const { username } = await request.json() - if (!username) return NextResponse.json({ error: '用户名不能为空' }, { status: 400 }) - if (username === 'admin' || username === 'localadmin') { - return NextResponse.json({ error: '不能删除系统保留用户' }, { status: 400 }) - } - - const safeUser = username.replace(/'/g, "''") - - // 删除 LLDAP 用户 - const lldapSQL = `DELETE FROM users WHERE user_id='${safeUser}';` - await execAsync( - `docker exec lldap /bin/sh -c "cat > /tmp/del.sql <<'EOSQL'\n${lldapSQL}\nEOSQL\nsqlite3 /data/users.db < /tmp/del.sql"`, - { timeout: 5000 } - ) - - // 删除各站点本地用户 - const results: Record = {} - for (const [site, dbPath] of Object.entries({ - assets: process.env.ASSETS_DB_PATH || '/Users/niuniu/programs/docker/assets-ai/data/assets.db', - issue: process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db', - })) { - try { - await execAsync(`sqlite3 "${dbPath}" "DELETE FROM users WHERE username='${safeUser}';"`, { timeout: 3000 }) - results[site] = true - } catch { results[site] = false } - } - - return NextResponse.json({ success: true, deleted: results }) - } catch (e) { - return NextResponse.json({ error: '删除失败' }, { status: 500 }) - } -} - -// PATCH — 修改用户信息(admin 权限) -export async function PATCH(request: Request) { - const isAdmin = await checkAdmin()() - if (!isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - - try { - const { username, email, displayName } = await request.json() - if (!username) return NextResponse.json({ error: '用户名不能为空' }, { status: 400 }) - if (email === undefined && displayName === undefined) { - return NextResponse.json({ error: '至少需要 email 或 displayName' }, { status: 400 }) - } - if (email !== undefined && email !== '' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { - return NextResponse.json({ error: '邮箱格式不合法' }, { status: 400 }) - } - - const safeUser = username.replace(/'/g, "''") - const d = new Date() - const now = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}` - - // 更新 LLDAP - let lldapSets: string[] = [] - let siteSets: string[] = [] - if (email !== undefined) { - const safeEmail = (email || '').replace(/'/g, "''") - lldapSets.push(`email = '${safeEmail}'`, `lowercase_email = LOWER('${safeEmail}')`) - siteSets.push(`email = '${safeEmail}'`) - } - if (displayName !== undefined) { - const safeName = displayName.replace(/'/g, "''") - lldapSets.push(`display_name = '${safeName}'`) - siteSets.push(`display_name = '${safeName}'`) - } - lldapSets.push(`modified_date = '${now}'`) - siteSets.push(`updated_at = datetime('now', '+8 hours')`) - - const lldapSQL = `UPDATE users SET ${lldapSets.join(', ')} WHERE user_id = '${safeUser}';` - await execAsync( - `docker exec lldap /bin/sh -c "cat > /tmp/up.sql <<'EOSQL'\n${lldapSQL}\nEOSQL\nsqlite3 /data/users.db < /tmp/up.sql"`, - { timeout: 5000 } - ) - - // 同步更新 assets / issue - const assetsDb = process.env.ASSETS_DB_PATH || '/Users/niuniu/programs/docker/assets-ai/data/assets.db' - const issueDb = process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db' - const siteSQL = `UPDATE users SET ${siteSets.join(', ')} WHERE username = '${safeUser}';` - for (const dbPath of [assetsDb, issueDb]) { - try { await execAsync(`sqlite3 "${dbPath}" "${siteSQL}"`, { timeout: 3000 }) } catch {} - } - - return NextResponse.json({ success: true, username, email, displayName }) - } catch (e) { - return NextResponse.json({ error: '修改失败' }, { status: 500 }) - } -} diff --git a/src/app/api/auth/callback/route.ts b/src/app/api/auth/callback/route.ts index 15e6f81..d07b825 100644 --- a/src/app/api/auth/callback/route.ts +++ b/src/app/api/auth/callback/route.ts @@ -2,6 +2,7 @@ import { NextRequest } from 'next/server' import { handleOidcCallback } from '@shared/lib/auth/handle-callback' import { ldapUserExists } from '@/lib/ldap' +import { syncUserToAllSites } from '@/lib/sync-user' const autheliaUrl = process.env.AUTHELIA_URL || 'https://sso.tlyq.ai' const oidcClientId = process.env.OIDC_CLIENT_ID || 'oa-oidc' @@ -11,7 +12,7 @@ const jwtSecret = process.env.JWT_SECRET || 'oa-shared-jwt-secret-tlyq-2026' const cookieDomain = process.env.COOKIE_DOMAIN || '.tlyq.ai' export async function GET(request: NextRequest) { - return handleOidcCallback(request, { + const response = await handleOidcCallback(request, { oidc: { autheliaUrl, clientId: oidcClientId, clientSecret: oidcClientSecret, redirectUri: oidcRedirectUri }, jwtSecret, cookieDomain, @@ -19,9 +20,16 @@ export async function GET(request: NextRequest) { // 强制验证 LLDAP 存在性(违反 §2.3 的旧行为已修正) getUser: async (username) => { const exists = await ldapUserExists(username) - return exists ? { id: -1, role: 'admin' } : null + if (exists) { + // 跨站点角色同步(fire-and-forget,不阻塞 callback 响应) + syncUserToAllSites(username, username, 'admin').catch(() => {}) + return { id: -1, role: 'admin' } + } + return null }, // OA 不通过 OIDC 创建/更新用户 }) + + return response } diff --git "a/src/app/api/auth/callback/route.ts\"\"" "b/src/app/api/auth/callback/route.ts\"\"" deleted file mode 100644 index 15e6f81..0000000 --- "a/src/app/api/auth/callback/route.ts\"\"" +++ /dev/null @@ -1,27 +0,0 @@ -// GET /api/auth/callback — OIDC callback(V2:OA 签发 tlyq_session) -import { NextRequest } from 'next/server' -import { handleOidcCallback } from '@shared/lib/auth/handle-callback' -import { ldapUserExists } from '@/lib/ldap' - -const autheliaUrl = process.env.AUTHELIA_URL || 'https://sso.tlyq.ai' -const oidcClientId = process.env.OIDC_CLIENT_ID || 'oa-oidc' -const oidcClientSecret = process.env.OIDC_CLIENT_SECRET || '' -const oidcRedirectUri = process.env.OIDC_REDIRECT_URI || 'https://oa.tlyq.ai/api/auth/callback' -const jwtSecret = process.env.JWT_SECRET || 'oa-shared-jwt-secret-tlyq-2026' -const cookieDomain = process.env.COOKIE_DOMAIN || '.tlyq.ai' - -export async function GET(request: NextRequest) { - return handleOidcCallback(request, { - oidc: { autheliaUrl, clientId: oidcClientId, clientSecret: oidcClientSecret, redirectUri: oidcRedirectUri }, - jwtSecret, - cookieDomain, - - // 强制验证 LLDAP 存在性(违反 §2.3 的旧行为已修正) - getUser: async (username) => { - const exists = await ldapUserExists(username) - return exists ? { id: -1, role: 'admin' } : null - }, - - // OA 不通过 OIDC 创建/更新用户 - }) -} diff --git "a/src/app/api/auth/change-password/route.ts\"\"" "b/src/app/api/auth/change-password/route.ts\"\"" deleted file mode 100644 index 420b7c9..0000000 --- "a/src/app/api/auth/change-password/route.ts\"\"" +++ /dev/null @@ -1,56 +0,0 @@ -import { NextResponse } from 'next/server' -import { cookies } from 'next/headers' -import { exec } from 'child_process' -import { promisify } from 'util' -import { verifySharedJwt } from '@/lib/jwt' - -const execAsync = promisify(exec) - -export async function POST(request: Request) { - try { - const cookieStore = await cookies() - const token = cookieStore.get('tlyq_session')?.value - if (!token) return NextResponse.json({ error: '未登录' }, { status: 401 }) - - const session = verifySharedJwt(token) - if (!session) return NextResponse.json({ error: '会话已过期' }, { status: 401 }) - - const { currentPassword, newPassword } = await request.json() - if (!currentPassword || !newPassword) { - return NextResponse.json({ error: '请输入当前密码和新密码' }, { status: 400 }) - } - if (newPassword.length < 8) { - return NextResponse.json({ error: '新密码至少 8 位' }, { status: 400 }) - } - // 密码复杂度:大写/小写/数字/特殊字符 4选3 - const hasUpper = /[A-Z]/.test(newPassword) - const hasLower = /[a-z]/.test(newPassword) - const hasDigit = /[0-9]/.test(newPassword) - const hasSpecial = /[^A-Za-z0-9]/.test(newPassword) - const complexityScore = [hasUpper, hasLower, hasDigit, hasSpecial].filter(Boolean).length - if (complexityScore < 3) { - return NextResponse.json({ error: '密码需包含大写字母、小写字母、数字、特殊字符中至少 3 种' }, { status: 400 }) - } - - // 从 LLDAP 容器动态获取 admin 密码(不硬编码,admin 改密码后无需改 OA 配置) - const { stdout: adminPassOut } = await execAsync('docker exec lldap printenv LLDAP_ADMIN_PASSWORD', { timeout: 3000 }) - const adminPass = (adminPassOut.trim() || 'admin123').replace(/'/g, "'\\''") - - const safeUser = session.username.replace(/'/g, "'\\''") - const safePass = newPassword.replace(/'/g, "'\\''") - const cmd = `docker exec lldap ./lldap_set_password --base-url http://localhost:17170 --admin-username admin --admin-password '${adminPass}' --username '${safeUser}' --password '${safePass}'` - - const { stdout, stderr } = await execAsync(cmd, { timeout: 10000 }) - if (stderr && !stderr.includes('Successfully')) { - return NextResponse.json({ error: stderr.trim() || '修改失败' }, { status: 500 }) - } - - return NextResponse.json({ success: true }) - } catch (err) { - const msg = err instanceof Error ? err.message : '修改失败' - if (msg.includes('command not found') || msg.includes('No such container')) { - return NextResponse.json({ error: '密码服务不可用' }, { status: 503 }) - } - return NextResponse.json({ error: msg }, { status: 500 }) - } -} diff --git "a/src/app/api/auth/login/oidc/route.ts\"\"" "b/src/app/api/auth/login/oidc/route.ts\"\"" deleted file mode 100644 index ac98a8b..0000000 --- "a/src/app/api/auth/login/oidc/route.ts\"\"" +++ /dev/null @@ -1,13 +0,0 @@ -// GET /api/auth/login/oidc — OIDC SSO 重定向(V2:使用 shared handleOidcLogin 工厂) -import { handleOidcLogin } from '@shared/lib/auth/handle-login' - -const autheliaUrl = process.env.AUTHELIA_URL || 'https://sso.tlyq.ai' -const oidcClientId = process.env.OIDC_CLIENT_ID || 'oa-oidc' -const oidcClientSecret = process.env.OIDC_CLIENT_SECRET || '' -const oidcRedirectUri = process.env.OIDC_REDIRECT_URI || 'https://oa.tlyq.ai/api/auth/callback' - -export async function GET(request: Request) { - const url = new URL(request.url) - const switchUser = url.searchParams.get('switch') === '1' - return handleOidcLogin({ autheliaUrl, clientId: oidcClientId, clientSecret: oidcClientSecret, redirectUri: oidcRedirectUri, switchUser }) -} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 95a5acf..c3695ad 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,7 +1,9 @@ import { NextResponse } from 'next/server' import { cookies } from 'next/headers' import { signSharedJwt, sharedCookieConfig } from '@/lib/jwt' -import { ldapAuth } from '@/lib/ldap' +import { ldapAuth, isLldapAdmin } from '@/lib/ldap' +import { writeAuditLog } from '@/lib/audit' +import { syncUserToAllSites } from '@/lib/sync-user' export async function POST(request: Request) { try { @@ -23,6 +25,20 @@ export async function POST(request: Request) { const cookieStore = await cookies() cookieStore.set(cfg.name, token, cfg) + // 审计日志 + try { + writeAuditLog({ + username: result.username!, + action: 'login', + details: { method: 'ldap', displayName: result.displayName }, + ipAddress: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown', + }) + } catch { /* 审计日志失败不影响登录 */ } + + // 跨站点角色同步(不阻塞响应) + const role = (await isLldapAdmin(result.username!)) ? 'admin' : 'viewer' + syncUserToAllSites(result.username!, result.displayName!, role).catch(() => {}) + return NextResponse.json({ user: { username: result.username, displayName: result.displayName }, }) diff --git "a/src/app/api/auth/login/route.ts\"\"" "b/src/app/api/auth/login/route.ts\"\"" deleted file mode 100644 index 95a5acf..0000000 --- "a/src/app/api/auth/login/route.ts\"\"" +++ /dev/null @@ -1,32 +0,0 @@ -import { NextResponse } from 'next/server' -import { cookies } from 'next/headers' -import { signSharedJwt, sharedCookieConfig } from '@/lib/jwt' -import { ldapAuth } from '@/lib/ldap' - -export async function POST(request: Request) { - try { - const { username, password } = await request.json() - if (!username || !password) { - return NextResponse.json({ error: '请输入用户名和密码' }, { status: 400 }) - } - - const result = await ldapAuth(username, password) - if (!result.success) { - if (result.unreachable) { - return NextResponse.json({ error: '认证服务暂时不可用,请稍后再试' }, { status: 503 }) - } - return NextResponse.json({ error: '用户名或密码错误' }, { status: 401 }) - } - - const token = signSharedJwt({ username: result.username!, displayName: result.displayName! }) - const cfg = sharedCookieConfig() - const cookieStore = await cookies() - cookieStore.set(cfg.name, token, cfg) - - return NextResponse.json({ - user: { username: result.username, displayName: result.displayName }, - }) - } catch { - return NextResponse.json({ error: '登录失败' }, { status: 500 }) - } -} diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts index 4e205c7..273f1b2 100644 --- a/src/app/api/auth/logout/route.ts +++ b/src/app/api/auth/logout/route.ts @@ -7,7 +7,7 @@ const cookieDomain = process.env.COOKIE_DOMAIN || '.tlyq.ai' function getSiteUrl(): string { const redirectUri = process.env.OIDC_REDIRECT_URI || '' try { const u = new URL(redirectUri); return `${u.protocol}//${u.host}` } catch { /* fallthrough */ } - return process.env.NEXT_PUBLIC_SITE_URL || 'https://oa.tlyq.ai' + return process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:6179' } /** 清除 tlyq_session + session cookie → 302 跳转 /login */ diff --git "a/src/app/api/auth/logout/route.ts\"\"" "b/src/app/api/auth/logout/route.ts\"\"" deleted file mode 100644 index 9fe8b6e..0000000 --- "a/src/app/api/auth/logout/route.ts\"\"" +++ /dev/null @@ -1,30 +0,0 @@ -// POST /api/auth/logout — 退出登录(清除 cookie + 302 跳转 /login) -import { NextResponse } from 'next/server' - -const autheliaUrl = process.env.AUTHELIA_URL || 'https://sso.tlyq.ai' -const cookieDomain = process.env.COOKIE_DOMAIN || '.tlyq.ai' - -// GET: 浏览器导航 → 302 到 Authelia end_session(带 rd 回跳 /login) -export async function GET() { - const returnUrl = `${process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:6179'}/login` - const response = NextResponse.redirect( - `${autheliaUrl}/api/oidc/end_session?rd=${encodeURIComponent(returnUrl)}` - ) - response.cookies.set('tlyq_session', '', { - httpOnly: true, secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', domain: cookieDomain, path: '/', maxAge: 0, - }) - response.cookies.set('session', '', { path: '/', maxAge: 0 }) - return response -} - -// POST: fetch 调用 → 清除 cookie + 返回 200 JSON -export async function POST() { - const response = NextResponse.json({ success: true }) - response.cookies.set('tlyq_session', '', { - httpOnly: true, secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', domain: cookieDomain, path: '/', maxAge: 0, - }) - response.cookies.set('session', '', { path: '/', maxAge: 0 }) - return response -} diff --git "a/src/app/api/auth/me/route.ts\"\"" "b/src/app/api/auth/me/route.ts\"\"" deleted file mode 100644 index 642e4a8..0000000 --- "a/src/app/api/auth/me/route.ts\"\"" +++ /dev/null @@ -1,83 +0,0 @@ -import { NextResponse } from 'next/server' -import { cookies } from 'next/headers' -import { exec } from 'child_process' -import { promisify } from 'util' -import { verifySharedJwt } from '@/lib/jwt' -import { isLldapAdmin } from '@/lib/ldap' - -const execAsync = promisify(exec) - -async function getLldapInfo(username: string): Promise<{ email: string; displayName: string }> { - try { - const safeUser = username.replace(/'/g, "''") - const { stdout } = await execAsync( - `docker exec lldap /bin/sh -c "echo 'SELECT email, display_name FROM users WHERE user_id='\\''${safeUser}'\\'';' | sqlite3 /data/users.db"`, - { timeout: 3000 } - ) - const parts = stdout.trim().split('|') - return { email: parts[0] || '', displayName: parts[1] || username } - } catch { return { email: '', displayName: username } } -} - -export async function GET() { - try { - const cookieStore = await cookies() - const token = cookieStore.get('tlyq_session')?.value - if (!token) return NextResponse.json({ error: '未登录' }, { status: 401 }) - - const payload = verifySharedJwt(token) - if (!payload) return NextResponse.json({ error: '会话已过期' }, { status: 401 }) - - const [admin, info] = await Promise.all([ - isLldapAdmin(payload.username), - getLldapInfo(payload.username), - ]) - - return NextResponse.json({ - user: { username: payload.username, displayName: info.displayName, email: info.email, isAdmin: admin }, - }) - } catch { - return NextResponse.json({ error: '获取用户信息失败' }, { status: 500 }) - } -} - -export async function PUT(request: Request) { - try { - const cookieStore = await cookies() - const token = cookieStore.get('tlyq_session')?.value - if (!token) return NextResponse.json({ error: '未登录' }, { status: 401 }) - - const payload = verifySharedJwt(token) - if (!payload) return NextResponse.json({ error: '会话已过期' }, { status: 401 }) - - const { email } = await request.json() - if (email !== '' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { - return NextResponse.json({ error: '邮箱格式不合法' }, { status: 400 }) - } - - const safeUser = payload.username.replace(/'/g, "''") - const safeEmail = (email || '').replace(/'/g, "''") - const d = new Date() - const now = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}` - - const updateSQL = `UPDATE users SET email = '${safeEmail}', lowercase_email = LOWER('${safeEmail}'), modified_date = '${now}' WHERE user_id = '${safeUser}';` - await execAsync( - `docker exec lldap /bin/sh -c "cat > /tmp/ue.sql <<'EOSQL'\n${updateSQL}\nEOSQL\nsqlite3 /data/users.db < /tmp/ue.sql"`, - { timeout: 5000 } - ) - - // 同步更新 assets / issue 本地用户表 - const assetsDb = process.env.ASSETS_DB_PATH || '/Users/niuniu/programs/docker/assets-ai/data/assets.db' - const issueDb = process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db' - for (const dbPath of [assetsDb, issueDb]) { - try { - await execAsync(`sqlite3 "${dbPath}" "UPDATE users SET email = '${safeEmail}', updated_at = datetime('now', '+8 hours') WHERE username = '${safeUser}';"`, { timeout: 3000 }) - } catch {} - } - - return NextResponse.json({ success: true, email: email || '' }) - } catch (e) { - const msg = e instanceof Error ? e.message : '修改失败' - return NextResponse.json({ error: msg }, { status: 500 }) - } -} diff --git "a/src/app/api/auth/setup-password/route.ts\"\"" "b/src/app/api/auth/setup-password/route.ts\"\"" deleted file mode 100644 index 312dfe2..0000000 --- "a/src/app/api/auth/setup-password/route.ts\"\"" +++ /dev/null @@ -1,54 +0,0 @@ -import { NextResponse } from 'next/server' -import { exec } from 'child_process' -import { promisify } from 'util' -import { verifySetupToken } from '@/lib/setup-token' - -const execAsync = promisify(exec) - -export async function POST(request: Request) { - try { - const { token, password } = await request.json() - if (!token || !password) { - return NextResponse.json({ error: '参数不完整' }, { status: 400 }) - } - - const payload = verifySetupToken(token) - if (!payload) { - return NextResponse.json({ error: '链接已过期或无效,请联系管理员重新创建账号' }, { status: 403 }) - } - - if (password.length < 8) { - return NextResponse.json({ error: '密码至少 8 位' }, { status: 400 }) - } - const hasUpper = /[A-Z]/.test(password) - const hasLower = /[a-z]/.test(password) - const hasDigit = /[0-9]/.test(password) - const hasSpecial = /[^A-Za-z0-9]/.test(password) - const score = [hasUpper, hasLower, hasDigit, hasSpecial].filter(Boolean).length - if (score < 3) { - return NextResponse.json({ error: '密码需包含大写字母、小写字母、数字、特殊字符中至少 3 种' }, { status: 400 }) - } - - const { stdout: adminPassOut } = await execAsync( - 'docker exec lldap printenv LLDAP_ADMIN_PASSWORD', { timeout: 3000 } - ) - const adminPass = (adminPassOut.trim() || 'admin123').replace(/'/g, "'\\''") - - const safeUser = payload.username.replace(/'/g, "'\\''") - const safePass = password.replace(/'/g, "'\\''") - const cmd = `docker exec lldap ./lldap_set_password --base-url http://localhost:17170 --admin-username admin --admin-password '${adminPass}' --username '${safeUser}' --password '${safePass}'` - - const { stderr } = await execAsync(cmd, { timeout: 10000 }) - if (stderr && !stderr.includes('Successfully')) { - return NextResponse.json({ error: stderr.trim() || '设置失败' }, { status: 500 }) - } - - return NextResponse.json({ success: true }) - } catch (err) { - const msg = err instanceof Error ? err.message : '设置失败' - if (msg.includes('command not found') || msg.includes('No such container')) { - return NextResponse.json({ error: '密码服务不可用' }, { status: 503 }) - } - return NextResponse.json({ error: msg }, { status: 500 }) - } -} diff --git "a/src/app/api/health/route.ts\"\"" "b/src/app/api/health/route.ts\"\"" deleted file mode 100644 index 1a4d458..0000000 --- "a/src/app/api/health/route.ts\"\"" +++ /dev/null @@ -1,5 +0,0 @@ -import { NextResponse } from 'next/server' - -export async function GET() { - return NextResponse.json({ status: 'OK' }) -} diff --git a/src/lib/audit.ts b/src/lib/audit.ts new file mode 100644 index 0000000..7bf16ae --- /dev/null +++ b/src/lib/audit.ts @@ -0,0 +1,17 @@ +// oa-ai/src/lib/audit.ts — 审计日志写入 +import db from '@/lib/db' + +export function writeAuditLog(params: { + username: string + action: string + details?: Record + ipAddress: string +}) { + db.prepare( + `INSERT INTO audit_logs (username, action, details, ip_address) + VALUES (?, ?, ?, ?)` + ).run( + params.username, params.action, + JSON.stringify(params.details || {}), params.ipAddress + ) +} diff --git a/src/lib/db.ts b/src/lib/db.ts new file mode 100644 index 0000000..fa956cc --- /dev/null +++ b/src/lib/db.ts @@ -0,0 +1,23 @@ +// oa-ai/src/lib/db.ts — 仅为审计日志创建的 SQLite 数据库 +import Database from 'better-sqlite3' +import path from 'path' +import fs from 'fs' + +const dbPath = process.env.DATABASE_PATH || './data/oa.db' +const dbDir = path.dirname(dbPath) +if (!fs.existsSync(dbDir)) fs.mkdirSync(dbDir, { recursive: true }) + +const db = new Database(dbPath) +db.pragma('journal_mode = WAL') + +// 初始化 audit_logs 表 +db.exec(`CREATE TABLE IF NOT EXISTS audit_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT, + action TEXT, + details TEXT, + ip_address TEXT, + created_at TEXT DEFAULT (datetime('now', '+8 hours')) +)`) + +export default db diff --git a/src/lib/ldap.ts b/src/lib/ldap.ts index dc547db..05463be 100644 --- a/src/lib/ldap.ts +++ b/src/lib/ldap.ts @@ -3,17 +3,26 @@ import { Client, InvalidCredentialsError } from 'ldapts' const LDAP_URL = process.env.LDAP_URL || 'ldap://localhost:3890' const LDAP_BASE_DN = process.env.LDAP_BASE_DN || 'dc=tlyq,dc=ai' -// 从环境变量获取 LLDAP admin 密码(Docker 容器内无法执行 docker exec) +// 从环境变量获取 LLDAP admin 密码(优先使用环境变量,fallback 到 docker exec) function getLdapAdminPassword(): string { - return process.env.LLDAP_ADMIN_PASSWORD || 'admin123' + if (process.env.LLDAP_ADMIN_PASSWORD) { + return process.env.LLDAP_ADMIN_PASSWORD + } + try { + const { execFileSync } = require('child_process') as typeof import('child_process') + return execFileSync('docker', ['exec', 'lldap', 'printenv', 'LLDAP_ADMIN_PASSWORD'], + { timeout: 3000 }).toString().trim() + } catch { + throw new Error('无法获取 LLDAP admin 密码:请设置 LLDAP_ADMIN_PASSWORD 环境变量或确保 Docker socket 可用') + } } // 验证用户是否存在于 LLDAP 中(用于 OIDC callback 验证) export async function ldapUserExists(username: string): Promise { const adminDn = `uid=admin,ou=people,${LDAP_BASE_DN}` - const adminPass = getLdapAdminPassword() const client = new Client({ url: LDAP_URL, timeout: 5000 }) try { + const adminPass = getLdapAdminPassword() await client.bind(adminDn, adminPass) const { searchEntries } = await client.search(LDAP_BASE_DN, { scope: 'sub', filter: `(uid=${username})`, timeLimit: 3, @@ -27,10 +36,10 @@ export async function ldapUserExists(username: string): Promise { export async function isLldapAdmin(username: string): Promise { if (username === 'admin') return true // 默认 admin 永远是管理员 const adminDn = `uid=admin,ou=people,${LDAP_BASE_DN}` - const adminPass = getLdapAdminPassword() const client = new Client({ url: LDAP_URL, timeout: 5000 }) try { + const adminPass = getLdapAdminPassword() await client.bind(adminDn, adminPass) const userDn = `uid=${username},ou=people,${LDAP_BASE_DN}` const { searchEntries } = await client.search(`ou=groups,${LDAP_BASE_DN}`, { @@ -42,7 +51,7 @@ export async function isLldapAdmin(username: string): Promise { } catch { return false // LLDAP 不可达 → 保守拒绝,非 admin 不放行 } finally { - await client.unbind() + try { await client.unbind() } catch { /* */ } } } diff --git a/src/lib/sync-user.ts b/src/lib/sync-user.ts new file mode 100644 index 0000000..b556128 --- /dev/null +++ b/src/lib/sync-user.ts @@ -0,0 +1,34 @@ +// oa-ai/src/lib/sync-user.ts — 跨站点用户角色同步 +const SYNC_TARGETS = [ + { key: 'MONITOR_INTERNAL_URL', name: 'monitor-ai' }, + { key: 'ASSETS_INTERNAL_URL', name: 'assets-ai' }, + { key: 'ISSUE_INTERNAL_URL', name: 'issue-ai' }, +] + +export async function syncUserToAllSites( + username: string, + displayName: string, + role: string +): Promise { + const internalKey = process.env.INTERNAL_API_KEY + if (!internalKey) { + console.warn('同步跳过:INTERNAL_API_KEY 未配置') + return + } + + await Promise.allSettled(SYNC_TARGETS.map(({ key, name }) => { + const baseUrl = process.env[key] + if (!baseUrl) { + console.warn(`同步跳过 ${name}:${key} 未配置`) + return Promise.resolve() + } + return fetch(`${baseUrl}/api/internal/users`, { + method: 'POST', + headers: { + 'x-internal-key': internalKey || '', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ username, displayName, role }), + }).catch(e => console.error(`同步失败 ${name}:`, e)) + })) +}