From 7624c383e8b1d670c8b5f1b3ad46c21b9f9c112b Mon Sep 17 00:00:00 2001 From: iRubbish Date: Mon, 25 Aug 2025 18:03:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=95=B4=E5=AE=9E=E7=8E=B0AD?= =?UTF-8?q?=E5=9F=9F=E6=8E=A7=E7=94=A8=E6=88=B7=E8=AE=A4=E8=AF=81=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主要功能: - 新增LDAP服务连接AD域控服务器 - 实现多格式AD用户认证(sAMAccountName, UPN, 域\用户名, DN) - 支持中文显示名和拼音用户名搜索 - 添加用户账户状态检查(禁用账户检测) - 实现JWT token认证和用户会话管理 新增文件: - src/services/ldapService.js - LDAP核心服务 - src/routes/ldapRoutes.js - AD认证API路由 - src/services/userMappingService.js - 用户映射服务 - web/admin-spa/src/views/UserDashboardView.vue - 用户控制台 - web/admin-spa/src/components/user/ - 用户组件目录 修改功能: - ApiStatsView.vue 增加用户登录按钮和模态框 - 路由系统增加用户专用页面 - 安装ldapjs和jsonwebtoken依赖 技术特性: - 多种认证格式自动尝试 - LDAP referral错误处理 - 详细认证日志和错误码记录 - 前后端完整用户认证流程 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .env.example | 11 +- package-lock.json | 321 +++++++- package.json | 2 + src/app.js | 2 + src/routes/ldapRoutes.js | 562 +++++++++++++ src/services/ldapService.js | 750 ++++++++++++++++++ src/services/userMappingService.js | 195 +++++ .../src/components/user/UserApiKeysView.vue | 361 +++++++++ .../src/components/user/UserStatsView.vue | 268 +++++++ web/admin-spa/src/router/index.js | 20 +- web/admin-spa/src/views/ApiStatsView.vue | 192 +++++ web/admin-spa/src/views/UserDashboardView.vue | 357 +++++++++ 12 files changed, 3037 insertions(+), 4 deletions(-) create mode 100644 src/routes/ldapRoutes.js create mode 100644 src/services/ldapService.js create mode 100644 src/services/userMappingService.js create mode 100644 web/admin-spa/src/components/user/UserApiKeysView.vue create mode 100644 web/admin-spa/src/components/user/UserStatsView.vue create mode 100644 web/admin-spa/src/views/UserDashboardView.vue diff --git a/.env.example b/.env.example index bdf204cf..817731a8 100644 --- a/.env.example +++ b/.env.example @@ -65,4 +65,13 @@ TRUST_PROXY=true WEBHOOK_ENABLED=true WEBHOOK_URLS=https://your-webhook-url.com/notify,https://backup-webhook.com/notify WEBHOOK_TIMEOUT=10000 -WEBHOOK_RETRIES=3 \ No newline at end of file +WEBHOOK_RETRIES=3 + +# 🏢 LDAP/AD 域控配置 +LDAP_URL=ldap://172.25.3.100:389 +LDAP_BIND_DN=LDAP-Proxy-Read +LDAP_BIND_PASSWORD=Y%77JsVK8W +LDAP_BASE_DN=OU=微店,DC=corp,DC=weidian-inc,DC=com +LDAP_SEARCH_FILTER=(&(objectClass=user)(cn={username})) +LDAP_TIMEOUT=10000 +LDAP_CONNECT_TIMEOUT=10000 \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 98b89998..72fbe9fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,8 @@ "https-proxy-agent": "^7.0.2", "inquirer": "^8.2.6", "ioredis": "^5.3.2", + "jsonwebtoken": "^9.0.2", + "ldapjs": "^3.0.7", "morgan": "^1.10.0", "ora": "^5.4.1", "rate-limiter-flexible": "^5.0.5", @@ -2048,6 +2050,101 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@ldapjs/asn1": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@ldapjs/asn1/-/asn1-2.0.0.tgz", + "integrity": "sha512-G9+DkEOirNgdPmD0I8nu57ygQJKOOgFEMKknEuQvIHbGLwP3ny1mY+OTUYLCbCaGJP4sox5eYgBJRuSUpnAddA==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT" + }, + "node_modules/@ldapjs/attribute": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@ldapjs/attribute/-/attribute-1.0.0.tgz", + "integrity": "sha512-ptMl2d/5xJ0q+RgmnqOi3Zgwk/TMJYG7dYMC0Keko+yZU6n+oFM59MjQOUht5pxJeS4FWrImhu/LebX24vJNRQ==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "2.0.0", + "@ldapjs/protocol": "^1.2.1", + "process-warning": "^2.1.0" + } + }, + "node_modules/@ldapjs/change": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@ldapjs/change/-/change-1.0.0.tgz", + "integrity": "sha512-EOQNFH1RIku3M1s0OAJOzGfAohuFYXFY4s73wOhRm4KFGhmQQ7MChOh2YtYu9Kwgvuq1B0xKciXVzHCGkB5V+Q==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "2.0.0", + "@ldapjs/attribute": "1.0.0" + } + }, + "node_modules/@ldapjs/controls": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@ldapjs/controls/-/controls-2.1.0.tgz", + "integrity": "sha512-2pFdD1yRC9V9hXfAWvCCO2RRWK9OdIEcJIos/9cCVP9O4k72BY1bLDQQ4KpUoJnl4y/JoD4iFgM+YWT3IfITWw==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "^1.2.0", + "@ldapjs/protocol": "^1.2.1" + } + }, + "node_modules/@ldapjs/controls/node_modules/@ldapjs/asn1": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ldapjs/asn1/-/asn1-1.2.0.tgz", + "integrity": "sha512-KX/qQJ2xxzvO2/WOvr1UdQ+8P5dVvuOLk/C9b1bIkXxZss8BaR28njXdPgFCpj5aHaf1t8PmuVnea+N9YG9YMw==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT" + }, + "node_modules/@ldapjs/dn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@ldapjs/dn/-/dn-1.1.0.tgz", + "integrity": "sha512-R72zH5ZeBj/Fujf/yBu78YzpJjJXG46YHFo5E4W1EqfNpo1UsVPqdLrRMXeKIsJT3x9dJVIfR6OpzgINlKpi0A==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "2.0.0", + "process-warning": "^2.1.0" + } + }, + "node_modules/@ldapjs/filter": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@ldapjs/filter/-/filter-2.1.1.tgz", + "integrity": "sha512-TwPK5eEgNdUO1ABPBUQabcZ+h9heDORE4V9WNZqCtYLKc06+6+UAJ3IAbr0L0bYTnkkWC/JEQD2F+zAFsuikNw==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "2.0.0", + "@ldapjs/protocol": "^1.2.1", + "process-warning": "^2.1.0" + } + }, + "node_modules/@ldapjs/messages": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ldapjs/messages/-/messages-1.3.0.tgz", + "integrity": "sha512-K7xZpXJ21bj92jS35wtRbdcNrwmxAtPwy4myeh9duy/eR3xQKvikVycbdWVzkYEAVE5Ce520VXNOwCHjomjCZw==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "^2.0.0", + "@ldapjs/attribute": "^1.0.0", + "@ldapjs/change": "^1.0.0", + "@ldapjs/controls": "^2.1.0", + "@ldapjs/dn": "^1.1.0", + "@ldapjs/filter": "^2.1.1", + "@ldapjs/protocol": "^1.2.1", + "process-warning": "^2.2.0" + } + }, + "node_modules/@ldapjs/protocol": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@ldapjs/protocol/-/protocol-1.2.1.tgz", + "integrity": "sha512-O89xFDLW2gBoZWNXuXpBSM32/KealKCTb3JGtJdtUQc7RjAk8XzrRgyz02cPAwGKwKPxy0ivuC7UP9bmN87egQ==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT" + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmmirror.com/@noble/hashes/-/hashes-1.8.0.tgz", @@ -2921,6 +3018,12 @@ "dev": true, "license": "ISC" }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz", @@ -3077,6 +3180,15 @@ "dev": true, "license": "MIT" }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/astral-regex": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/astral-regex/-/astral-regex-2.0.0.tgz", @@ -3225,6 +3337,18 @@ "@babel/core": "^7.0.0" } }, + "node_modules/backoff": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/backoff/-/backoff-2.5.0.tgz", + "integrity": "sha512-wC5ihrnUXmR2douXmXLCe5O3zg3GKIyvRi/hi58a/XyRxVI+3/yM0PYueQOZXPXQ9pxBislYkw+sF9b7C/RuMA==", + "license": "MIT", + "dependencies": { + "precond": "0.2" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", @@ -3912,6 +4036,12 @@ "dev": true, "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmmirror.com/cors/-/cors-2.8.5.tgz", @@ -4633,6 +4763,15 @@ "node": ">=4" } }, + "node_modules/extsprintf": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -6477,6 +6616,67 @@ "node": ">=6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/jwa": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", + "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jsonwebtoken/node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jsonwebtoken/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/jwa": { "version": "2.0.1", "resolved": "https://registry.npmmirror.com/jwa/-/jwa-2.0.1.tgz", @@ -6524,6 +6724,29 @@ "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", "license": "MIT" }, + "node_modules/ldapjs": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/ldapjs/-/ldapjs-3.0.7.tgz", + "integrity": "sha512-1ky+WrN+4CFMuoekUOv7Y1037XWdjKpu0xAPwSP+9KdvmV9PG+qOKlssDV6a+U32apwxdD3is/BZcWOYzN30cg==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "^2.0.0", + "@ldapjs/attribute": "^1.0.0", + "@ldapjs/change": "^1.0.0", + "@ldapjs/controls": "^2.1.0", + "@ldapjs/dn": "^1.1.0", + "@ldapjs/filter": "^2.1.1", + "@ldapjs/messages": "^1.3.0", + "@ldapjs/protocol": "^1.2.1", + "abstract-logging": "^2.0.1", + "assert-plus": "^1.0.0", + "backoff": "^2.5.0", + "once": "^1.4.0", + "vasync": "^2.2.1", + "verror": "^1.10.1" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmmirror.com/leven/-/leven-3.1.0.tgz", @@ -6583,12 +6806,48 @@ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, "node_modules/lodash.isarguments": { "version": "3.1.0", "resolved": "https://registry.npmmirror.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", "license": "MIT" }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -6596,6 +6855,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/lodash.truncate": { "version": "4.4.2", "resolved": "https://registry.npmmirror.com/lodash.truncate/-/lodash.truncate-4.4.2.tgz", @@ -7096,7 +7361,6 @@ "version": "1.4.0", "resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -7401,6 +7665,14 @@ "node": ">=8" } }, + "node_modules/precond": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/precond/-/precond-0.2.3.tgz", + "integrity": "sha512-QCYG84SgGyGzqJ/vlMsxeXd/pgL/I94ixdNFyh1PusWmTCyVfPJjZ1K1jvHtsbfnXQs2TSkEP2fR7QiMZAnKFQ==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -7468,6 +7740,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/process-warning": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-2.3.2.tgz", + "integrity": "sha512-n9wh8tvBe5sFmsqlg+XQhaQLumwpqoAUruLwjCopgTmUBjJ/fjtBsJzKleCaIGBOMXYEhp1YfKl4d7rJ5ZKJGA==", + "license": "MIT" + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmmirror.com/prompts/-/prompts-2.4.2.tgz", @@ -8757,6 +9035,46 @@ "node": ">= 0.8" } }, + "node_modules/vasync": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/vasync/-/vasync-2.2.1.tgz", + "integrity": "sha512-Hq72JaTpcTFdWiNA4Y22Amej2GH3BFmBaKPPlDZ4/oC8HNn2ISHLkFrJU4Ds8R3jcUi7oo5Y9jcMHKjES+N9wQ==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "dependencies": { + "verror": "1.10.0" + } + }, + "node_modules/vasync/node_modules/verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "node_modules/verror": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmmirror.com/walker/-/walker-1.0.8.tgz", @@ -8911,7 +9229,6 @@ "version": "1.0.2", "resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/write-file-atomic": { diff --git a/package.json b/package.json index 48d7c604..bdfaf284 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,8 @@ "https-proxy-agent": "^7.0.2", "inquirer": "^8.2.6", "ioredis": "^5.3.2", + "jsonwebtoken": "^9.0.2", + "ldapjs": "^3.0.7", "morgan": "^1.10.0", "ora": "^5.4.1", "rate-limiter-flexible": "^5.0.5", diff --git a/src/app.js b/src/app.js index f80347c8..45d6ed6e 100644 --- a/src/app.js +++ b/src/app.js @@ -23,6 +23,7 @@ const openaiClaudeRoutes = require('./routes/openaiClaudeRoutes') const openaiRoutes = require('./routes/openaiRoutes') const azureOpenaiRoutes = require('./routes/azureOpenaiRoutes') const webhookRoutes = require('./routes/webhook') +const ldapRoutes = require('./routes/ldapRoutes') // Import middleware const { @@ -244,6 +245,7 @@ class Application { this.app.use('/openai', openaiRoutes) this.app.use('/azure', azureOpenaiRoutes) this.app.use('/admin/webhook', webhookRoutes) + this.app.use('/admin/ldap', ldapRoutes) // 🏠 根路径重定向到新版管理界面 this.app.get('/', (req, res) => { diff --git a/src/routes/ldapRoutes.js b/src/routes/ldapRoutes.js new file mode 100644 index 00000000..4608bb8d --- /dev/null +++ b/src/routes/ldapRoutes.js @@ -0,0 +1,562 @@ +const express = require('express') +const ldapService = require('../services/ldapService') +const logger = require('../utils/logger') + +const router = express.Router() + +/** + * 测试LDAP/AD连接 + */ +router.get('/test-connection', async (req, res) => { + try { + logger.info('LDAP connection test requested') + const result = await ldapService.testConnection() + + if (result.success) { + res.json({ + success: true, + message: 'LDAP/AD connection successful', + data: result + }) + } else { + res.status(500).json({ + success: false, + message: 'LDAP/AD connection failed', + error: result.error, + config: result.config + }) + } + } catch (error) { + logger.error('LDAP connection test error:', error) + res.status(500).json({ + success: false, + message: 'LDAP connection test failed', + error: error.message + }) + } +}) + +/** + * 获取LDAP配置信息 + */ +router.get('/config', (req, res) => { + try { + const config = ldapService.getConfig() + res.json({ + success: true, + config + }) + } catch (error) { + logger.error('Get LDAP config error:', error) + res.status(500).json({ + success: false, + message: 'Failed to get LDAP config', + error: error.message + }) + } +}) + +/** + * 搜索用户 + */ +router.post('/search-user', async (req, res) => { + try { + const { username } = req.body + + if (!username) { + return res.status(400).json({ + success: false, + message: 'Username is required' + }) + } + + logger.info(`Searching for user: ${username}`) + + await ldapService.createConnection() + await ldapService.bind() + + const users = await ldapService.searchUser(username) + + res.json({ + success: true, + message: `Found ${users.length} users`, + users + }) + } catch (error) { + logger.error('User search error:', error) + res.status(500).json({ + success: false, + message: 'User search failed', + error: error.message + }) + } finally { + ldapService.disconnect() + } +}) + +/** + * 列出所有用户(模拟Python代码的describe_ou功能) + */ +router.get('/list-users', async (req, res) => { + try { + const { limit = 20, type = 'human' } = req.query + const limitNum = parseInt(limit) + + logger.info(`Listing users with limit: ${limitNum}, type: ${type}`) + + await ldapService.createConnection() + await ldapService.bind() + + const users = await ldapService.listAllUsers(limitNum, type) + + res.json({ + success: true, + message: `Found ${users.length} users`, + users, + total: users.length, + limit: limitNum, + type + }) + } catch (error) { + logger.error('List users error:', error) + res.status(500).json({ + success: false, + message: 'List users failed', + error: error.message + }) + } finally { + ldapService.disconnect() + } +}) + +/** + * 测试用户认证 + */ +router.post('/test-auth', async (req, res) => { + try { + const { username, password } = req.body + + if (!username || !password) { + return res.status(400).json({ + success: false, + message: 'Username and password are required' + }) + } + + logger.info(`Testing authentication for user: ${username}`) + + const result = await ldapService.authenticateUser(username, password) + + res.json({ + success: true, + message: 'Authentication successful', + user: result.user + }) + } catch (error) { + logger.error('User authentication test error:', error) + res.status(401).json({ + success: false, + message: 'Authentication failed', + error: error.message + }) + } +}) + +/** + * 列出所有OU + */ +router.get('/list-ous', async (req, res) => { + try { + logger.info('Listing all OUs in domain') + + await ldapService.createConnection() + await ldapService.bind() + + const ous = await ldapService.listOUs() + + res.json({ + success: true, + message: `Found ${ous.length} OUs`, + ous + }) + } catch (error) { + logger.error('List OUs error:', error) + res.status(500).json({ + success: false, + message: 'List OUs failed', + error: error.message + }) + } finally { + ldapService.disconnect() + } +}) + +/** + * 验证OU是否存在 + */ +router.get('/verify-ou', async (req, res) => { + try { + const { ou = '微店' } = req.query + const testDN = `OU=${ou},DC=corp,DC=weidian-inc,DC=com` + + logger.info(`Verifying OU exists: ${testDN}`) + + await ldapService.createConnection() + await ldapService.bind() + + const result = await ldapService.verifyOU(testDN) + + res.json({ + success: true, + message: 'OU verification completed', + testDN, + result + }) + } catch (error) { + logger.error('OU verification error:', error) + res.status(500).json({ + success: false, + message: 'OU verification failed', + error: error.message + }) + } finally { + ldapService.disconnect() + } +}) + +/** + * LDAP服务状态检查 + */ +router.get('/status', async (req, res) => { + try { + const config = ldapService.getConfig() + + // 简单的连接测试 + const connectionTest = await ldapService.testConnection() + + res.json({ + success: true, + status: connectionTest.success ? 'connected' : 'disconnected', + config, + lastTest: new Date().toISOString(), + testResult: connectionTest + }) + } catch (error) { + logger.error('LDAP status check error:', error) + res.status(500).json({ + success: false, + status: 'error', + message: 'Status check failed', + error: error.message + }) + } +}) + +/** + * AD用户登录认证 + */ +router.post('/login', async (req, res) => { + try { + const { username, password } = req.body + + if (!username || !password) { + return res.status(400).json({ + success: false, + message: '用户名和密码不能为空' + }) + } + + logger.info(`AD用户登录尝试: ${username}`) + + // 使用AD认证用户 + const authResult = await ldapService.authenticateUser(username, password) + + // 生成用户会话token + const jwt = require('jsonwebtoken') + const config = require('../../config/config') + + const userInfo = { + type: 'ad_user', + username: authResult.user.username || authResult.user.cn, + displayName: authResult.user.displayName, + email: authResult.user.email, + groups: authResult.user.groups, + loginTime: new Date().toISOString() + } + + const token = jwt.sign(userInfo, config.security.jwtSecret, { + expiresIn: '8h' // 8小时过期 + }) + + logger.info(`AD用户登录成功: ${username}`) + + res.json({ + success: true, + message: '登录成功', + token, + user: userInfo + }) + } catch (error) { + logger.error('AD用户登录失败:', error) + res.status(401).json({ + success: false, + message: '用户名或密码错误', + error: error.message + }) + } +}) + +/** + * AD用户token验证 + */ +router.get('/verify-token', (req, res) => { + try { + const authHeader = req.headers.authorization + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ + success: false, + message: '未提供有效的认证token' + }) + } + + const token = authHeader.substring(7) + const jwt = require('jsonwebtoken') + const config = require('../../config/config') + + const decoded = jwt.verify(token, config.security.jwtSecret) + + if (decoded.type !== 'ad_user') { + return res.status(403).json({ + success: false, + message: '无效的用户类型' + }) + } + + res.json({ + success: true, + user: decoded + }) + } catch (error) { + logger.error('Token验证失败:', error) + res.status(401).json({ + success: false, + message: 'Token无效或已过期' + }) + } +}) + +/** + * AD用户认证中间件 + */ +const authenticateUser = (req, res, next) => { + try { + const authHeader = req.headers.authorization + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ + success: false, + message: '未提供有效的认证token' + }) + } + + const token = authHeader.substring(7) + const jwt = require('jsonwebtoken') + const config = require('../../config/config') + + const decoded = jwt.verify(token, config.security.jwtSecret) + + if (decoded.type !== 'ad_user') { + return res.status(403).json({ + success: false, + message: '无效的用户类型' + }) + } + + req.user = decoded + next() + } catch (error) { + logger.error('用户认证失败:', error) + res.status(401).json({ + success: false, + message: 'Token无效或已过期' + }) + } +} + +/** + * 获取用户的API Keys + */ +router.get('/user/api-keys', authenticateUser, async (req, res) => { + try { + const redis = require('../models/redis') + const { username } = req.user + + logger.info(`获取用户API Keys: ${username}`) + + // 获取所有API Keys + const allKeysPattern = 'api_key:*' + const keys = await redis.getClient().keys(allKeysPattern) + + const userKeys = [] + + // 筛选属于该用户的API Keys + for (const key of keys) { + const apiKeyData = await redis.getClient().hgetall(key) + if (apiKeyData && apiKeyData.owner === username) { + userKeys.push({ + id: apiKeyData.id, + name: apiKeyData.name || '未命名', + key: apiKeyData.key, + limit: parseInt(apiKeyData.limit) || 1000000, + used: parseInt(apiKeyData.used) || 0, + createdAt: apiKeyData.createdAt, + status: apiKeyData.status || 'active' + }) + } + } + + res.json({ + success: true, + apiKeys: userKeys + }) + } catch (error) { + logger.error('获取用户API Keys失败:', error) + res.status(500).json({ + success: false, + message: '获取API Keys失败' + }) + } +}) + +/** + * 创建用户API Key + */ +router.post('/user/api-keys', authenticateUser, async (req, res) => { + try { + const { username } = req.user + const { name, limit } = req.body + + // 检查用户是否已有API Key + const redis = require('../models/redis') + const allKeysPattern = 'api_key:*' + const keys = await redis.getClient().keys(allKeysPattern) + + let userKeyCount = 0 + for (const key of keys) { + const apiKeyData = await redis.getClient().hgetall(key) + if (apiKeyData && apiKeyData.owner === username) { + userKeyCount++ + } + } + + if (userKeyCount >= 1) { + return res.status(400).json({ + success: false, + message: '每个用户只能创建一个API Key' + }) + } + + // 生成API Key + const crypto = require('crypto') + const uuid = require('uuid') + + const keyId = uuid.v4() + const apiKey = `cr_${crypto.randomBytes(32).toString('hex')}` + + const keyData = { + id: keyId, + key: apiKey, + name: name || 'AD用户密钥', + limit: limit || 100000, + used: 0, + owner: username, + ownerType: 'ad_user', + createdAt: new Date().toISOString(), + status: 'active' + } + + // 存储到Redis + await redis.getClient().hset(`api_key:${keyId}`, keyData) + + // 创建哈希映射以快速查找 + const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex') + await redis.getClient().set(`api_key_hash:${keyHash}`, keyId) + + logger.info(`用户${username}创建API Key成功: ${keyId}`) + + res.json({ + success: true, + message: 'API Key创建成功', + apiKey: { + id: keyId, + key: apiKey, + name: keyData.name, + limit: keyData.limit, + used: 0, + createdAt: keyData.createdAt, + status: keyData.status + } + }) + } catch (error) { + logger.error('创建用户API Key失败:', error) + res.status(500).json({ + success: false, + message: '创建API Key失败' + }) + } +}) + +/** + * 获取用户API Key使用统计 + */ +router.get('/user/usage-stats', authenticateUser, async (req, res) => { + try { + const { username } = req.user + const redis = require('../models/redis') + + // 获取用户的API Keys + const allKeysPattern = 'api_key:*' + const keys = await redis.getClient().keys(allKeysPattern) + + let totalUsage = 0 + let totalLimit = 0 + const userKeys = [] + + for (const key of keys) { + const apiKeyData = await redis.getClient().hgetall(key) + if (apiKeyData && apiKeyData.owner === username) { + const used = parseInt(apiKeyData.used) || 0 + const limit = parseInt(apiKeyData.limit) || 0 + + totalUsage += used + totalLimit += limit + + userKeys.push({ + id: apiKeyData.id, + name: apiKeyData.name, + used, + limit, + percentage: limit > 0 ? Math.round((used / limit) * 100) : 0 + }) + } + } + + res.json({ + success: true, + stats: { + totalUsage, + totalLimit, + percentage: totalLimit > 0 ? Math.round((totalUsage / totalLimit) * 100) : 0, + keyCount: userKeys.length, + keys: userKeys + } + }) + } catch (error) { + logger.error('获取用户使用统计失败:', error) + res.status(500).json({ + success: false, + message: '获取使用统计失败' + }) + } +}) + +module.exports = router diff --git a/src/services/ldapService.js b/src/services/ldapService.js new file mode 100644 index 00000000..1630cb72 --- /dev/null +++ b/src/services/ldapService.js @@ -0,0 +1,750 @@ +const ldap = require('ldapjs') +const logger = require('../utils/logger') + +class LDAPService { + constructor() { + this.client = null + this.config = { + url: process.env.LDAP_URL || 'ldap://172.25.3.100:389', + bindDN: process.env.LDAP_BIND_DN || 'LDAP-Proxy-Read', + bindPassword: process.env.LDAP_BIND_PASSWORD || 'Y%77JsVK8W', + baseDN: process.env.LDAP_BASE_DN || 'OU=微店,DC=corp,DC=weidian-inc,DC=com', + searchFilter: process.env.LDAP_SEARCH_FILTER || '(&(objectClass=user)(cn={username}))', + timeout: parseInt(process.env.LDAP_TIMEOUT) || 10000, + connectTimeout: parseInt(process.env.LDAP_CONNECT_TIMEOUT) || 10000 + } + } + + /** + * 创建LDAP连接 + */ + createConnection() { + return new Promise((resolve, reject) => { + const options = { + url: this.config.url, + timeout: this.config.timeout, + connectTimeout: this.config.connectTimeout, + reconnect: false, + // 匹配Python代码中的设置:禁用referrals + followReferrals: false, + // LDAP协议版本3 + version: 3, + // 增加兼容性选项 + strictDN: false + } + + this.client = ldap.createClient(options) + + // 连接超时处理 + const timeoutTimer = setTimeout(() => { + this.client.destroy() + reject(new Error(`LDAP connection timeout after ${this.config.connectTimeout}ms`)) + }, this.config.connectTimeout) + + // 连接成功 + this.client.on('connect', () => { + clearTimeout(timeoutTimer) + logger.info('LDAP connection established successfully') + resolve() + }) + + // 连接错误 + this.client.on('error', (err) => { + clearTimeout(timeoutTimer) + logger.error('LDAP connection error:', err) + reject(err) + }) + + // 连接关闭 + this.client.on('close', () => { + logger.info('LDAP connection closed') + }) + }) + } + + /** + * 绑定LDAP连接(认证) + */ + bind() { + return new Promise((resolve, reject) => { + if (!this.client) { + return reject(new Error('LDAP client not initialized')) + } + + this.client.bind(this.config.bindDN, this.config.bindPassword, (err) => { + if (err) { + logger.error('LDAP bind failed:', err) + reject(err) + } else { + logger.info('LDAP bind successful') + resolve() + } + }) + }) + } + + /** + * 测试AD域控连接 + */ + async testConnection() { + try { + logger.info('Testing LDAP/AD connection...') + logger.info(`Connecting to: ${this.config.url}`) + logger.info(`Bind DN: ${this.config.bindDN}`) + logger.info(`Base DN: ${this.config.baseDN}`) + + await this.createConnection() + await this.bind() + + // 先测试连接和绑定是否真的成功 + logger.info('LDAP connection and bind successful') + + // 尝试简单的根 DSE 查询来验证连接 + let searchResult = null + try { + searchResult = await this.testRootDSE() + logger.info('Root DSE query successful') + } catch (searchError) { + logger.warn('Root DSE query failed, trying base search:', searchError.message) + try { + searchResult = await this.testSearch() + } catch (baseSearchError) { + logger.warn('Base search also failed:', baseSearchError.message) + // 连接成功但搜索失败,仍然返回部分成功 + return { + success: true, + message: + 'LDAP connection and authentication successful, but search requires DN adjustment', + connectionTest: 'SUCCESS', + authTest: 'SUCCESS', + searchTest: `FAILED - ${baseSearchError.message}`, + config: { + url: this.config.url, + bindDN: this.config.bindDN, + baseDN: this.config.baseDN, + searchFilter: this.config.searchFilter + } + } + } + } + + logger.info('LDAP/AD full connection test successful') + return { + success: true, + message: 'LDAP/AD connection test successful', + connectionTest: 'SUCCESS', + authTest: 'SUCCESS', + searchTest: 'SUCCESS', + config: { + url: this.config.url, + bindDN: this.config.bindDN, + baseDN: this.config.baseDN, + searchFilter: this.config.searchFilter + }, + searchResult + } + } catch (error) { + logger.error('LDAP/AD connection test failed:', error) + return { + success: false, + message: `LDAP/AD connection test failed: ${error.message}`, + error: error.message, + connectionTest: error.message.includes('connect') ? 'FAILED' : 'UNKNOWN', + authTest: + error.message.includes('bind') || error.message.includes('authentication') + ? 'FAILED' + : 'UNKNOWN', + config: { + url: this.config.url, + bindDN: this.config.bindDN, + baseDN: this.config.baseDN + } + } + } finally { + this.disconnect() + } + } + + /** + * 测试根DSE查询(最基本的LDAP查询) + */ + testRootDSE() { + return new Promise((resolve, reject) => { + const searchOptions = { + filter: '(objectClass=*)', + scope: 'base', + attributes: ['*'] + } + + this.client.search('', searchOptions, (err, res) => { + if (err) { + reject(err) + return + } + + let rootDSE = null + + res.on('searchEntry', (entry) => { + rootDSE = { + dn: entry.dn, + namingContexts: entry.object?.namingContexts || entry.attributes?.namingContexts, + supportedLDAPVersion: + entry.object?.supportedLDAPVersion || entry.attributes?.supportedLDAPVersion, + defaultNamingContext: + entry.object?.defaultNamingContext || entry.attributes?.defaultNamingContext, + raw: entry.object || entry.attributes + } + }) + + res.on('referral', (referral) => { + logger.info(`Root DSE referral: ${referral}`) + }) + + res.on('error', (error) => { + if (error.message && error.message.toLowerCase().includes('referral')) { + logger.warn(`Root DSE referral error (ignored): ${error.message}`) + return + } + reject(error) + }) + + res.on('end', () => { + if (rootDSE) { + logger.info('Root DSE query completed successfully') + resolve(rootDSE) + } else { + resolve({ message: 'No Root DSE data returned' }) + } + }) + }) + }) + } + + /** + * 执行测试搜索 + */ + testSearch() { + return new Promise((resolve, reject) => { + // 匹配Python代码的搜索:查找用户对象,获取CN和userAccountControl属性 + const searchOptions = { + filter: '(objectClass=user)', + scope: 'sub', // SCOPE_SUBTREE in Python + attributes: ['CN', 'userAccountControl'], + sizeLimit: 10 // 限制结果数量 + } + + this.client.search(this.config.baseDN, searchOptions, (err, res) => { + if (err) { + reject(err) + return + } + + let entryCount = 0 + const entries = [] + + res.on('searchEntry', (entry) => { + entryCount++ + entries.push({ + dn: entry.dn, + cn: entry.object.CN || entry.object.cn, + userAccountControl: entry.object.userAccountControl + }) + }) + + res.on('referral', (referral) => { + // 记录referral但不作为错误处理 + logger.info(`LDAP referral received: ${referral}`) + }) + + res.on('error', (error) => { + // 如果是referral相关错误,不视为失败 + if (error.message && error.message.toLowerCase().includes('referral')) { + logger.warn(`LDAP referral error (ignored): ${error.message}`) + return + } + reject(error) + }) + + res.on('end', (result) => { + logger.info( + `Search test completed. Found ${entryCount} entries, status: ${result.status}` + ) + resolve({ + entryCount, + status: result.status, + entries: entries.slice(0, 5) + }) + }) + }) + }) + } + + /** + * 根据用户名搜索用户 + */ + searchUser(username) { + return new Promise((resolve, reject) => { + if (!this.client) { + return reject(new Error('LDAP client not initialized')) + } + + const filter = this.config.searchFilter.replace(/{username}/g, username) + const searchOptions = { + filter, + scope: 'sub', + attributes: [ + 'dn', + 'sAMAccountName', + 'displayName', + 'mail', + 'memberOf', + 'cn', + 'userAccountControl' + ] + } + + logger.info(`Searching for user: ${username}, Filter: ${filter}`) + + this.client.search(this.config.baseDN, searchOptions, (err, res) => { + if (err) { + reject(err) + return + } + + const users = [] + + res.on('searchEntry', (entry) => { + const obj = entry.object || {} + const attrs = entry.attributes || [] + + // 创建属性查找函数 + const getAttr = (name) => { + if (obj[name]) { + return obj[name] + } + const attr = attrs.find((a) => a.type === name) + return attr ? (Array.isArray(attr.values) ? attr.values[0] : attr.values) : null + } + + const user = { + dn: entry.dn, + username: getAttr('sAMAccountName'), + displayName: getAttr('displayName'), + email: getAttr('mail'), + cn: getAttr('cn'), + userAccountControl: getAttr('userAccountControl'), + groups: (() => { + const memberOf = getAttr('memberOf') + return Array.isArray(memberOf) ? memberOf : memberOf ? [memberOf] : [] + })() + } + users.push(user) + }) + + res.on('referral', (referral) => { + logger.info(`LDAP referral received during user search: ${referral}`) + }) + + res.on('error', (error) => { + if (error.message && error.message.toLowerCase().includes('referral')) { + logger.warn(`LDAP referral error during user search (ignored): ${error.message}`) + return + } + reject(error) + }) + + res.on('end', () => { + logger.info(`Found ${users.length} users for username: ${username}`) + resolve(users) + }) + }) + }) + } + + /** + * 列出所有用户(模拟Python代码的describe_ou功能) + */ + listAllUsers(limit = 20, type = 'human') { + return new Promise((resolve, reject) => { + if (!this.client) { + return reject(new Error('LDAP client not initialized')) + } + + // 根据类型选择不同的搜索过滤器 + let filter + if (type === 'computer') { + // 只显示计算机账户 + filter = '(&(objectClass=user)(sAMAccountName=*$))' + } else if (type === 'human') { + // 只显示人员账户(排除计算机账户) + filter = '(&(objectClass=user)(!(sAMAccountName=*$)))' + } else { + // 显示所有用户 + filter = '(objectClass=user)' + } + + const searchOptions = { + filter, + scope: 'sub', // SCOPE_SUBTREE + attributes: ['CN', 'userAccountControl', 'sAMAccountName', 'displayName', 'mail', 'dn'] + // 不使用 sizeLimit,而是在客户端限制结果数量 + } + + logger.info(`Listing all users with filter: ${searchOptions.filter}, limit: ${limit}`) + + this.client.search(this.config.baseDN, searchOptions, (err, res) => { + if (err) { + reject(err) + return + } + + const users = [] + + res.on('searchEntry', (entry) => { + // 如果已经达到限制,停止处理 + if (users.length >= limit) { + return + } + + const obj = entry.object || {} + const attrs = entry.attributes || [] + + // 创建属性查找函数 + const getAttr = (name) => { + if (obj[name]) { + return obj[name] + } + const attr = attrs.find((a) => a.type === name) + return attr ? (Array.isArray(attr.values) ? attr.values[0] : attr.values) : null + } + + const user = { + dn: entry.dn, + cn: getAttr('CN') || getAttr('cn'), + sAMAccountName: getAttr('sAMAccountName'), + displayName: getAttr('displayName'), + email: getAttr('mail'), + userAccountControl: getAttr('userAccountControl'), + // 为了兼容Python代码的数据结构 + org: entry.dn, + // 调试信息 (限制原始数据大小) + raw: users.length < 3 ? { object: entry.object, attributes: entry.attributes } : null + } + users.push(user) + }) + + res.on('referral', (referral) => { + logger.info(`LDAP referral received during user listing: ${referral}`) + }) + + res.on('error', (error) => { + if (error.message && error.message.toLowerCase().includes('referral')) { + logger.warn(`LDAP referral error during user listing (ignored): ${error.message}`) + return + } + reject(error) + }) + + res.on('end', () => { + logger.info(`Found ${users.length} users total`) + resolve(users) + }) + }) + }) + } + + /** + * 验证用户凭据 + */ + async authenticateUser(username, password) { + try { + // 先搜索用户获取DN + await this.createConnection() + await this.bind() + + const users = await this.searchUser(username) + if (users.length === 0) { + throw new Error('User not found') + } + + // 修复DN提取逻辑,处理ldapjs的DN对象 + let userDN = users[0].dn + if (userDN && typeof userDN === 'object') { + // ldapjs返回的是DN对象,需要正确转换为字符串 + if (userDN.toString && typeof userDN.toString === 'function') { + userDN = userDN.toString() + } else if (userDN.format && typeof userDN.format === 'function') { + userDN = userDN.format() + } else { + // 从dn对象中提取rdns信息手动构建DN字符串 + logger.info('User DN object structure:', JSON.stringify(userDN, null, 2)) + throw new Error('Unable to extract user DN from object') + } + } else if (typeof userDN !== 'string') { + throw new Error('Invalid DN format') + } + + logger.info(`Attempting to authenticate with DN: ${userDN}`) + logger.info(`User sAMAccountName: ${users[0].sAMAccountName || users[0].username}`) + logger.info(`User Account Control: ${users[0].userAccountControl}`) + + // 检查账户状态 + const userAccountControl = parseInt(users[0].userAccountControl) || 0 + if (userAccountControl & 2) { + // UF_ACCOUNTDISABLE = 2 + throw new Error('User account is disabled') + } + + // 断开管理员连接 + this.disconnect() + + // 尝试多种认证格式 + const sAMAccountName = users[0].sAMAccountName || users[0].username + const authFormats = [ + sAMAccountName, // 直接使用sAMAccountName + `${sAMAccountName}@corp.weidian-inc.com`, // UPN格式 + `${sAMAccountName}@weidian-inc.com`, // 简化UPN格式 + `corp\\${sAMAccountName}`, // 域\\用户名格式 + `CORP\\${sAMAccountName}`, // 大写域\\用户名格式 + `weidian-inc\\${sAMAccountName}`, // 完整域名\\用户名格式 + userDN // 完整DN(最后尝试) + ].filter(Boolean) + + logger.info(`Trying authentication with formats: ${JSON.stringify(authFormats)}`) + + for (const authFormat of authFormats) { + try { + logger.info(`Attempting authentication with: ${authFormat}`) + + const userClient = ldap.createClient({ + url: this.config.url, + timeout: 10000, + connectTimeout: 10000, + idleTimeout: 30000 + }) + + const authResult = await new Promise((resolve, reject) => { + let resolved = false + + // 设置错误处理 + userClient.on('error', (err) => { + if (!resolved) { + resolved = true + logger.warn(`Connection error with ${authFormat}:`, err.message) + userClient.destroy() + reject(err) + } + }) + + userClient.on('connect', () => { + logger.info(`Connected for authentication with: ${authFormat}`) + + // 尝试使用用户凭据绑定 + userClient.bind(authFormat, password, (err) => { + if (!resolved) { + resolved = true + if (err) { + logger.warn( + `Bind failed with ${authFormat}: ${err.name} - ${err.message} (Code: ${err.code})` + ) + userClient.destroy() + reject(err) + } else { + logger.info(`Bind successful with ${authFormat}`) + userClient.unbind() + resolve(true) + } + } + }) + }) + + // 超时处理 + setTimeout(() => { + if (!resolved) { + resolved = true + userClient.destroy() + reject(new Error('Authentication timeout')) + } + }, 5000) + }) + + if (authResult) { + logger.info(`User ${username} authenticated successfully with format: ${authFormat}`) + return { + success: true, + user: users[0] + } + } + } catch (err) { + logger.warn( + `Authentication failed with format ${authFormat}: ${err.name} - ${err.message}` + ) + continue + } + } + + // 所有格式都失败 + throw new Error('Invalid username or password') + } catch (error) { + logger.error('User authentication error:', error) + throw error + } finally { + this.disconnect() + } + } + + /** + * 关闭连接 + */ + disconnect() { + if (this.client) { + this.client.destroy() + this.client = null + logger.info('LDAP connection closed') + } + } + + /** + * 列出所有OU + */ + listOUs() { + return new Promise((resolve, reject) => { + if (!this.client) { + return reject(new Error('LDAP client not initialized')) + } + + const searchOptions = { + filter: '(objectClass=organizationalUnit)', + scope: 'sub', + attributes: ['ou', 'dn', 'objectClass', 'description'] + } + + // 从域根开始搜索所有OU + const baseDN = 'DC=corp,DC=weidian-inc,DC=com' + logger.info(`Searching for all OUs in: ${baseDN}`) + + this.client.search(baseDN, searchOptions, (err, res) => { + if (err) { + reject(err) + return + } + + const ous = [] + + res.on('searchEntry', (entry) => { + const obj = entry.object || {} + const attrs = entry.attributes || [] + + const getAttr = (name) => { + if (obj[name]) { + return obj[name] + } + const attr = attrs.find((a) => a.type === name) + return attr ? (Array.isArray(attr.values) ? attr.values[0] : attr.values) : null + } + + const ou = { + dn: entry.dn, + ou: getAttr('ou'), + description: getAttr('description'), + objectClass: getAttr('objectClass') + } + ous.push(ou) + }) + + res.on('referral', (referral) => { + logger.info(`OUs search referral: ${referral}`) + }) + + res.on('error', (error) => { + if (error.message && error.message.toLowerCase().includes('referral')) { + logger.warn(`OUs search referral error (ignored): ${error.message}`) + return + } + reject(error) + }) + + res.on('end', () => { + logger.info(`Found ${ous.length} OUs total`) + resolve(ous) + }) + }) + }) + } + + /** + * 验证OU是否存在 + */ + verifyOU(ouDN) { + return new Promise((resolve, reject) => { + if (!this.client) { + return reject(new Error('LDAP client not initialized')) + } + + const searchOptions = { + filter: '(objectClass=organizationalUnit)', + scope: 'base', + attributes: ['ou', 'dn', 'objectClass'] + } + + logger.info(`Searching for OU: ${ouDN}`) + + this.client.search(ouDN, searchOptions, (err, res) => { + if (err) { + reject(err) + return + } + + let found = false + let ouInfo = null + + res.on('searchEntry', (entry) => { + found = true + ouInfo = { + dn: entry.dn, + ou: entry.object?.ou || entry.attributes?.find((a) => a.type === 'ou')?.values, + objectClass: + entry.object?.objectClass || + entry.attributes?.find((a) => a.type === 'objectClass')?.values + } + }) + + res.on('referral', (referral) => { + logger.info(`OU search referral: ${referral}`) + }) + + res.on('error', (error) => { + if (error.message && error.message.toLowerCase().includes('referral')) { + logger.warn(`OU search referral error (ignored): ${error.message}`) + return + } + reject(error) + }) + + res.on('end', () => { + resolve({ + exists: found, + dn: ouDN, + info: ouInfo + }) + }) + }) + }) + } + + /** + * 获取配置信息(不包含密码) + */ + getConfig() { + return { + url: this.config.url, + bindDN: this.config.bindDN, + baseDN: this.config.baseDN, + searchFilter: this.config.searchFilter, + timeout: this.config.timeout, + connectTimeout: this.config.connectTimeout + } + } +} + +module.exports = new LDAPService() diff --git a/src/services/userMappingService.js b/src/services/userMappingService.js new file mode 100644 index 00000000..b79c2b23 --- /dev/null +++ b/src/services/userMappingService.js @@ -0,0 +1,195 @@ +const logger = require('../utils/logger') + +/** + * 用户映射服务 - 处理AD用户数据转换和过滤 + */ +class UserMappingService { + /** + * 解析AD用户账户控制状态 + */ + static parseUserAccountControl(uac) { + if (!uac) { + return { disabled: true, description: 'Unknown' } + } + + const uacValue = parseInt(uac) + const flags = { + SCRIPT: 0x00000001, + ACCOUNTDISABLE: 0x00000002, + HOMEDIR_REQUIRED: 0x00000008, + LOCKOUT: 0x00000010, + PASSWD_NOTREQD: 0x00000020, + PASSWD_CANT_CHANGE: 0x00000040, + ENCRYPTED_TEXT_PASSWORD_ALLOWED: 0x00000080, + TEMP_DUPLICATE_ACCOUNT: 0x00000100, + NORMAL_ACCOUNT: 0x00000200, + INTERDOMAIN_TRUST_ACCOUNT: 0x00000800, + WORKSTATION_TRUST_ACCOUNT: 0x00001000, + SERVER_TRUST_ACCOUNT: 0x00002000, + DONT_EXPIRE_PASSWD: 0x00010000, + MNS_LOGON_ACCOUNT: 0x00020000, + SMARTCARD_REQUIRED: 0x00040000, + TRUSTED_FOR_DELEGATION: 0x00080000, + NOT_DELEGATED: 0x00100000, + USE_DES_KEY_ONLY: 0x00200000, + DONT_REQUIRE_PREAUTH: 0x00400000, + PASSWORD_EXPIRED: 0x00800000, + TRUSTED_TO_AUTHENTICATE_FOR_DELEGATION: 0x01000000, + PARTIAL_SECRETS_ACCOUNT: 0x04000000 + } + + const status = { + disabled: !!(uacValue & flags.ACCOUNTDISABLE), + locked: !!(uacValue & flags.LOCKOUT), + passwordExpired: !!(uacValue & flags.PASSWORD_EXPIRED), + normalAccount: !!(uacValue & flags.NORMAL_ACCOUNT), + passwordNotRequired: !!(uacValue & flags.PASSWD_NOTREQD), + dontExpirePassword: !!(uacValue & flags.DONT_EXPIRE_PASSWD), + description: this.getUserAccountControlDescription(uacValue) + } + + return status + } + + /** + * 获取用户账户控制的描述 + */ + static getUserAccountControlDescription(uac) { + const uacValue = parseInt(uac) + + if (uacValue & 0x00000002) { + return 'Account Disabled' + } + if (uacValue & 0x00000010) { + return 'Account Locked' + } + if (uacValue & 0x00800000) { + return 'Password Expired' + } + if (uacValue & 0x00000200) { + return 'Normal User Account' + } + + return `UAC: ${uacValue}` + } + + /** + * 过滤和映射AD用户数据 + * 模拟Python代码中的get_ad()函数逻辑 + */ + static mapAdUsers(searchResults) { + if (!Array.isArray(searchResults)) { + return [] + } + + // 移除第一个元素(Python代码中的slist.pop(0)) + const userList = searchResults.slice(1) + const mappedUsers = [] + + for (const user of userList) { + try { + const userObj = { + org: user.dn || user.distinguishedName, + cn: null, + userAccountControl: null, + accountStatus: null + } + + // 提取CN + if (user.cn || user.CN) { + userObj.cn = user.cn || user.CN + } else { + // 如果没有CN属性,跳过此用户 + continue + } + + // 提取userAccountControl + if (user.userAccountControl) { + userObj.userAccountControl = user.userAccountControl + userObj.accountStatus = this.parseUserAccountControl(user.userAccountControl) + } else { + // 如果没有userAccountControl,跳过此用户 + continue + } + + mappedUsers.push(userObj) + } catch (error) { + logger.warn(`Error processing user entry: ${error.message}`, { user }) + continue + } + } + + return mappedUsers + } + + /** + * 过滤活跃用户(未禁用的账户) + */ + static filterActiveUsers(users) { + return users.filter((user) => user.accountStatus && !user.accountStatus.disabled) + } + + /** + * 根据用户名搜索(支持模糊匹配) + */ + static searchUsersByName(users, searchTerm) { + if (!searchTerm) { + return users + } + + const term = searchTerm.toLowerCase() + return users.filter((user) => user.cn && user.cn.toLowerCase().includes(term)) + } + + /** + * 格式化用户信息用于显示 + */ + static formatUserInfo(user) { + return { + name: user.cn, + distinguishedName: user.org, + accountControl: user.userAccountControl, + status: user.accountStatus + ? { + enabled: !user.accountStatus.disabled, + locked: user.accountStatus.locked, + description: user.accountStatus.description + } + : null + } + } + + /** + * 获取用户统计信息 + */ + static getUserStats(users) { + const stats = { + total: users.length, + active: 0, + disabled: 0, + locked: 0, + passwordExpired: 0 + } + + users.forEach((user) => { + if (user.accountStatus) { + if (!user.accountStatus.disabled) { + stats.active++ + } + if (user.accountStatus.disabled) { + stats.disabled++ + } + if (user.accountStatus.locked) { + stats.locked++ + } + if (user.accountStatus.passwordExpired) { + stats.passwordExpired++ + } + } + }) + + return stats + } +} + +module.exports = UserMappingService diff --git a/web/admin-spa/src/components/user/UserApiKeysView.vue b/web/admin-spa/src/components/user/UserApiKeysView.vue new file mode 100644 index 00000000..7f472ab6 --- /dev/null +++ b/web/admin-spa/src/components/user/UserApiKeysView.vue @@ -0,0 +1,361 @@ + + + + + diff --git a/web/admin-spa/src/components/user/UserStatsView.vue b/web/admin-spa/src/components/user/UserStatsView.vue new file mode 100644 index 00000000..58e2467b --- /dev/null +++ b/web/admin-spa/src/components/user/UserStatsView.vue @@ -0,0 +1,268 @@ + + + + + diff --git a/web/admin-spa/src/router/index.js b/web/admin-spa/src/router/index.js index 47680c7d..11a8cc46 100644 --- a/web/admin-spa/src/router/index.js +++ b/web/admin-spa/src/router/index.js @@ -11,6 +11,7 @@ const AccountsView = () => import('@/views/AccountsView.vue') const TutorialView = () => import('@/views/TutorialView.vue') const SettingsView = () => import('@/views/SettingsView.vue') const ApiStatsView = () => import('@/views/ApiStatsView.vue') +const UserDashboardView = () => import('@/views/UserDashboardView.vue') const routes = [ { @@ -41,6 +42,12 @@ const routes = [ component: ApiStatsView, meta: { requiresAuth: false } }, + { + path: '/user-dashboard', + name: 'UserDashboard', + component: UserDashboardView, + meta: { requiresAuth: false, userAuth: true } + }, { path: '/dashboard', component: MainLayout, @@ -133,7 +140,18 @@ router.beforeEach((to, from, next) => { // API Stats 页面不需要认证,直接放行 if (to.path === '/api-stats' || to.path.startsWith('/api-stats')) { next() - } else if (to.meta.requiresAuth && !authStore.isAuthenticated) { + } + // 用户仪表盘需要用户token验证 + else if (to.meta.userAuth) { + const userToken = localStorage.getItem('user_token') + if (!userToken) { + next('/api-stats') + } else { + next() + } + } + // 管理员页面需要管理员认证 + else if (to.meta.requiresAuth && !authStore.isAuthenticated) { next('/login') } else if (to.path === '/login' && authStore.isAuthenticated) { next('/dashboard') diff --git a/web/admin-spa/src/views/ApiStatsView.vue b/web/admin-spa/src/views/ApiStatsView.vue index f2af17b2..fcd6bd51 100644 --- a/web/admin-spa/src/views/ApiStatsView.vue +++ b/web/admin-spa/src/views/ApiStatsView.vue @@ -20,6 +20,15 @@ class="h-8 w-px bg-gradient-to-b from-transparent via-gray-300 to-transparent opacity-50 dark:via-gray-600" /> + + + + + +
+
+
+
+

AD域控登录

+

使用您的域账号登录

+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ {{ userLoginError }} +
+
+
@@ -157,6 +234,15 @@ const currentTab = ref('stats') // 主题相关 const isDarkMode = computed(() => themeStore.isDarkMode) +// 用户登录相关 +const showLoginModal = ref(false) +const userLoginLoading = ref(false) +const userLoginError = ref('') +const userLoginForm = ref({ + username: '', + password: '' +}) + const { apiKey, apiId, @@ -171,6 +257,63 @@ const { const { queryStats, switchPeriod, loadStatsWithApiId, loadOemSettings, reset } = apiStatsStore +// 用户登录相关方法 +const showUserLogin = () => { + showLoginModal.value = true + userLoginError.value = '' + userLoginForm.value = { + username: '', + password: '' + } +} + +const hideUserLogin = () => { + showLoginModal.value = false + userLoginError.value = '' + userLoginForm.value = { + username: '', + password: '' + } +} + +const handleUserLogin = async () => { + if (!userLoginForm.value.username || !userLoginForm.value.password) { + userLoginError.value = '请输入用户名和密码' + return + } + + userLoginLoading.value = true + userLoginError.value = '' + + try { + const response = await fetch('/admin/ldap/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(userLoginForm.value) + }) + + const result = await response.json() + + if (result.success) { + // 保存token到localStorage + localStorage.setItem('user_token', result.token) + localStorage.setItem('user_info', JSON.stringify(result.user)) + + // 跳转到用户专用页面 + window.location.href = '/admin-next/user-dashboard' + } else { + userLoginError.value = result.message || '登录失败' + } + } catch (error) { + console.error('用户登录错误:', error) + userLoginError.value = '网络错误,请重试' + } finally { + userLoginLoading.value = false + } +} + // 处理键盘快捷键 const handleKeyDown = (event) => { // Ctrl/Cmd + Enter 查询 @@ -309,6 +452,55 @@ watch(apiKey, (newValue) => { letter-spacing: -0.025em; } +/* 用户登录按钮 */ +.user-login-button { + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + backdrop-filter: blur(20px); + border: 1px solid rgba(255, 255, 255, 0.3); + color: white; + text-decoration: none; + box-shadow: + 0 4px 12px rgba(16, 185, 129, 0.25), + inset 0 1px 1px rgba(255, 255, 255, 0.2); + position: relative; + overflow: hidden; + font-weight: 600; + cursor: pointer; +} + +/* 暗色模式下的用户登录按钮 */ +:global(.dark) .user-login-button { + background: rgba(34, 197, 94, 0.8); + border: 1px solid rgba(107, 114, 128, 0.4); + color: #f3f4f6; + box-shadow: + 0 4px 12px rgba(0, 0, 0, 0.3), + inset 0 1px 1px rgba(255, 255, 255, 0.05); +} + +.user-login-button:hover { + transform: translateY(-2px) scale(1.02); + background: linear-gradient(135deg, #059669 0%, #10b981 100%); + box-shadow: + 0 8px 20px rgba(5, 150, 105, 0.35), + inset 0 1px 1px rgba(255, 255, 255, 0.3); + border-color: rgba(255, 255, 255, 0.4); + color: white; +} + +:global(.dark) .user-login-button:hover { + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + border-color: rgba(34, 197, 94, 0.4); + box-shadow: + 0 8px 20px rgba(16, 185, 129, 0.3), + inset 0 1px 1px rgba(255, 255, 255, 0.1); + color: white; +} + +.user-login-button:active { + transform: translateY(-1px) scale(1); +} + /* 管理后台按钮 - 精致版本 */ .admin-button-refined { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); diff --git a/web/admin-spa/src/views/UserDashboardView.vue b/web/admin-spa/src/views/UserDashboardView.vue new file mode 100644 index 00000000..74802f77 --- /dev/null +++ b/web/admin-spa/src/views/UserDashboardView.vue @@ -0,0 +1,357 @@ + + + + +