diff --git a/.env.example b/.env.example index bdf204cf..85ce6d2f 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,23 @@ ENCRYPTION_KEY=your-encryption-key-here # ADMIN_USERNAME=cr_admin_custom # ADMIN_PASSWORD=your-secure-password + +# 🏢 LDAP/Windows AD 域控认证配置(可选,用于企业内部用户登录) +# 启用LDAP认证功能 +# LDAP_ENABLED=true +# AD域控服务器地址 +# LDAP_URL=ldap://your-domain-controller-ip:389 +# 绑定用户 +# LDAP_BIND_DN=your-bind-user +# 绑定用户密码 +# LDAP_BIND_PASSWORD=your-bind-password +# 搜索基础DN +# LDAP_BASE_DN=OU=YourOU,DC=your,DC=domain,DC=com +# 用户搜索过滤器 +# LDAP_SEARCH_FILTER=(&(objectClass=user)(|(cn={username})(sAMAccountName={username}))) +# 连接超时设置 +# LDAP_TIMEOUT=10000 + # 📊 Redis 配置 REDIS_HOST=localhost REDIS_PORT=6379 @@ -45,8 +62,10 @@ LOG_MAX_FILES=5 CLEANUP_INTERVAL=3600000 TOKEN_USAGE_RETENTION=2592000000 HEALTH_CHECK_INTERVAL=60000 -TIMEZONE_OFFSET=8 # UTC偏移小时数,默认+8(中国时区) -METRICS_WINDOW=5 # 实时指标统计窗口(分钟),可选1-60,默认5分钟 +SYSTEM_TIMEZONE=Asia/Shanghai +TIMEZONE_OFFSET=8 +# 实时指标统计窗口(分钟),可选1-60,默认5分钟 +METRICS_WINDOW=5 # 🎨 Web 界面配置 WEB_TITLE=Claude Relay Service @@ -65,4 +84,5 @@ 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 + diff --git a/README.md b/README.md index 8e6cfd5a..addadc27 100644 --- a/README.md +++ b/README.md @@ -250,6 +250,15 @@ REDIS_HOST=localhost REDIS_PORT=6379 REDIS_PASSWORD= +# AD域控配置(可选,用于企业内部用户登录) +LDAP_ENABLED=true +LDAP_URL=ldap://your-domain-controller-ip:389 +LDAP_BIND_DN=your-bind-user +LDAP_BIND_PASSWORD=your-bind-password +LDAP_BASE_DN=DC=your-domain,DC=com +LDAP_SEARCH_FILTER=(&(objectClass=user)(|(cn={username})(sAMAccountName={username}))) +LDAP_TIMEOUT=10000 + # Webhook通知配置(可选) WEBHOOK_ENABLED=true WEBHOOK_URLS=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=your-key diff --git a/package-lock.json b/package-lock.json index 98b89998..c428539e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,10 @@ "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", + "node-fetch": "^2.7.0", "ora": "^5.4.1", "rate-limiter-flexible": "^5.0.5", "socks-proxy-agent": "^8.0.2", @@ -2048,6 +2051,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 +3019,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 +3181,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 +3338,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 +4037,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", @@ -3964,7 +4095,7 @@ }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", - "resolved": "https://registry.npmmirror.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", "license": "MIT", "engines": { @@ -4633,6 +4764,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", @@ -4729,7 +4869,7 @@ }, "node_modules/fetch-blob": { "version": "3.2.0", - "resolved": "https://registry.npmmirror.com/fetch-blob/-/fetch-blob-3.2.0.tgz", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", "funding": [ { @@ -4910,7 +5050,7 @@ }, "node_modules/formdata-polyfill": { "version": "4.0.10", - "resolved": "https://registry.npmmirror.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", "license": "MIT", "dependencies": { @@ -4999,6 +5139,24 @@ "node": ">=18" } }, + "node_modules/gaxios/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/gcp-metadata": { "version": "7.0.1", "resolved": "https://registry.npmmirror.com/gcp-metadata/-/gcp-metadata-7.0.1.tgz", @@ -6477,6 +6635,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 +6743,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 +6825,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 +6874,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", @@ -6878,7 +7162,7 @@ }, "node_modules/node-domexception": { "version": "1.0.0", - "resolved": "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", "deprecated": "Use your platform's native DOMException instead", "funding": [ @@ -6897,21 +7181,23 @@ } }, "node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmmirror.com/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "license": "MIT", "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" + "whatwg-url": "^5.0.0" }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": "4.x || >=6.0.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } } }, "node_modules/node-int64": { @@ -7096,7 +7382,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 +7686,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 +7761,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", @@ -8577,6 +8876,12 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/triple-beam": { "version": "1.4.1", "resolved": "https://registry.npmmirror.com/triple-beam/-/triple-beam-1.4.1.tgz", @@ -8757,6 +9062,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", @@ -8778,13 +9123,29 @@ }, "node_modules/web-streams-polyfill": { "version": "3.3.3", - "resolved": "https://registry.npmmirror.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", "license": "MIT", "engines": { "node": ">= 8" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", @@ -8911,7 +9272,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..3c08d7da 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,10 @@ "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", + "node-fetch": "^2.7.0", "ora": "^5.4.1", "rate-limiter-flexible": "^5.0.5", "socks-proxy-agent": "^8.0.2", diff --git a/src/app.js b/src/app.js index a1f8020b..00f4c8ae 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/admin.js b/src/routes/admin.js index 368d0a33..b356ff16 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -791,6 +791,8 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { try { const { keyId } = req.params const { + name, + description, tokenLimit, concurrencyLimit, rateLimitWindow, @@ -814,6 +816,30 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { // 只允许更新指定字段 const updates = {} + // 处理name字段 + if (name !== undefined) { + if (name === null || name === '') { + return res.status(400).json({ error: 'Name cannot be empty' }) + } + if (typeof name !== 'string' || name.trim().length === 0) { + return res.status(400).json({ error: 'Name must be a non-empty string' }) + } + if (name.length > 100) { + return res.status(400).json({ error: 'Name must be less than 100 characters' }) + } + updates.name = name.trim() + } + + // 处理description字段 + if (description !== undefined) { + if (description && (typeof description !== 'string' || description.length > 500)) { + return res + .status(400) + .json({ error: 'Description must be a string with less than 500 characters' }) + } + updates.description = description || '' + } + if (tokenLimit !== undefined && tokenLimit !== null && tokenLimit !== '') { if (!Number.isInteger(Number(tokenLimit)) || Number(tokenLimit) < 0) { return res.status(400).json({ error: 'Token limit must be a non-negative integer' }) @@ -954,12 +980,20 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { updates.isActive = isActive } + logger.info(`🔧 Admin updating API key: ${keyId}`, { + updates: Object.keys(updates), + updatesData: updates + }) + await apiKeyService.updateApiKey(keyId, updates) logger.success(`📝 Admin updated API key: ${keyId}`) return res.json({ success: true, message: 'API key updated successfully' }) } catch (error) { - logger.error('❌ Failed to update API key:', error) + logger.error(`❌ Failed to update API key ${req.params.keyId}:`, { + error: error.message, + stack: error.stack + }) return res.status(500).json({ error: 'Failed to update API key', message: error.message }) } }) diff --git a/src/routes/ldapRoutes.js b/src/routes/ldapRoutes.js new file mode 100644 index 00000000..66a49cf6 --- /dev/null +++ b/src/routes/ldapRoutes.js @@ -0,0 +1,689 @@ +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 defaultOU = process.env.LDAP_DEFAULT_OU || 'YourOU' + const { ou = defaultOU } = req.query + // 使用配置的baseDN来构建测试DN,而不是硬编码域名 + const config = ldapService.getConfig() + // 从baseDN中提取域部分,替换OU部分 + const baseDNParts = config.baseDN.split(',') + const domainParts = baseDNParts.filter((part) => part.trim().startsWith('DC=')) + const testDN = `OU=${ou},${domainParts.join(',')}` + + 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 + * + * 自动关联逻辑说明: + * 系统迁移过程中存在历史API Key,这些Key是在AD集成前手动创建的 + * 创建时使用的name字段恰好与AD用户的displayName一致 + * 例如: AD用户displayName为"测试用户",对应的API Key name也是"测试用户" + * 为了避免用户重复创建Key,系统会自动关联这些历史Key + * 关联规则: + * 1. 优先匹配owner字段(新建的Key) + * 2. 如果没有owner匹配,则尝试匹配name字段与displayName + * 3. 找到匹配的历史Key后,自动将owner设置为当前用户,完成关联 + */ +router.get('/user/api-keys', authenticateUser, async (req, res) => { + try { + const apiKeyService = require('../services/apiKeyService') + const redis = require('../models/redis') + const { username, displayName } = req.user + + logger.info(`获取用户API Keys: ${username}, displayName: ${displayName}`) + + // 使用与admin相同的API Key服务,获取所有API Keys的完整信息 + const allApiKeys = await apiKeyService.getAllApiKeys() + + const userKeys = [] + let foundHistoricalKey = false + + // 筛选属于该用户的API Keys,并处理自动关联 + for (const apiKey of allApiKeys) { + logger.debug( + `检查API Key: ${apiKey.id}, name: "${apiKey.name}", owner: "${apiKey.owner || '无'}", displayName: "${displayName}"` + ) + + // 规则1: 直接owner匹配(已关联的Key) + if (apiKey.owner === username) { + logger.info(`找到已关联的API Key: ${apiKey.id}`) + userKeys.push(apiKey) + } + // 规则2: 历史Key自动关联(name字段匹配displayName且无owner) + else if (displayName && apiKey.name === displayName && !apiKey.owner) { + logger.info( + `🔗 发现历史API Key需要关联: id=${apiKey.id}, name="${apiKey.name}", displayName="${displayName}"` + ) + + // 自动关联: 设置owner为当前用户 + await redis.getClient().hset(`apikey:${apiKey.id}`, 'owner', username) + foundHistoricalKey = true + + // 更新本地数据并添加到用户Key列表 + apiKey.owner = username + userKeys.push(apiKey) + + logger.info(`✅ 历史API Key关联成功: ${apiKey.id} -> ${username}`) + } + } + + if (foundHistoricalKey) { + logger.info(`用户 ${username} 自动关联了历史API Key`) + } + + 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 + // 用户创建的API Key不需要任何输入参数,都使用默认值 + // const { limit } = req.body // 不再从请求体获取limit + + // 检查用户是否已有API Key + const redis = require('../models/redis') + const allKeysPattern = 'apikey:*' + 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' + }) + } + + // 使用与admin相同的API Key生成服务,确保数据结构一致性 + const apiKeyService = require('../services/apiKeyService') + + // 获取用户的显示名称 + const { displayName } = req.user + // 用户创建的API Key名称固定为displayName,不允许自定义 + const defaultName = displayName || username + + const keyParams = { + name: defaultName, // 使用displayName作为API Key名称 + tokenLimit: 0, // 固定为无限制 + description: `AD用户${username}创建的API Key`, + // AD用户创建的Key添加owner信息以区分用户归属 + owner: username, + ownerType: 'ad_user', + // 确保用户创建的Key默认激活 + isActive: true, + // 设置基本权限(与admin创建保持一致) + permissions: 'all', + // 设置合理的并发和速率限制(与admin创建保持一致) + concurrencyLimit: 0, + rateLimitWindow: 0, + rateLimitRequests: 0, + // 添加标签标识AD用户创建 + tags: ['ad-user', 'user-created'] + } + + const newKey = await apiKeyService.generateApiKey(keyParams) + + logger.info(`用户${username}创建API Key成功: ${newKey.id}`) + + res.json({ + success: true, + message: 'API Key创建成功', + apiKey: { + id: newKey.id, + key: newKey.apiKey, // 返回完整的API Key + name: newKey.name, + tokenLimit: newKey.tokenLimit || 0, + used: 0, + createdAt: newKey.createdAt, + isActive: true, + usage: { + daily: { requests: 0, tokens: 0 }, + total: { requests: 0, tokens: 0 } + }, + dailyCost: 0 + } + }) + } 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 = 'apikey:*' + 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: '获取使用统计失败' + }) + } +}) + +/** + * 更新用户API Key + */ +router.put('/user/api-keys/:keyId', authenticateUser, async (req, res) => { + try { + const { username } = req.user + const { keyId } = req.params + const updates = req.body + + // 验证用户只能编辑自己的API Key + const apiKeyService = require('../services/apiKeyService') + const allApiKeys = await apiKeyService.getAllApiKeys() + const apiKey = allApiKeys.find((key) => key.id === keyId && key.owner === username) + + if (!apiKey) { + return res.status(404).json({ + success: false, + message: 'API Key 不存在或无权限' + }) + } + + // 限制用户只能修改特定字段(不允许修改name) + const allowedFields = ['description', 'isActive'] + const filteredUpdates = {} + for (const [key, value] of Object.entries(updates)) { + if (allowedFields.includes(key)) { + filteredUpdates[key] = value + } + } + + await apiKeyService.updateApiKey(keyId, filteredUpdates) + + logger.info(`用户 ${username} 更新了 API Key: ${keyId}`) + + res.json({ + success: true, + message: 'API Key 更新成功' + }) + } catch (error) { + logger.error('更新用户API Key失败:', error) + res.status(500).json({ + success: false, + message: '更新 API Key 失败' + }) + } +}) + +/** + * 删除用户API Key + */ +router.delete('/user/api-keys/:keyId', authenticateUser, async (req, res) => { + try { + const { username } = req.user + const { keyId } = req.params + + // 验证用户只能删除自己的API Key + const apiKeyService = require('../services/apiKeyService') + const allApiKeys = await apiKeyService.getAllApiKeys() + const apiKey = allApiKeys.find((key) => key.id === keyId && key.owner === username) + + if (!apiKey) { + return res.status(404).json({ + success: false, + message: 'API Key 不存在或无权限' + }) + } + + await apiKeyService.deleteApiKey(keyId) + + logger.info(`用户 ${username} 删除了 API Key: ${keyId}`) + + res.json({ + success: true, + message: 'API Key 删除成功' + }) + } catch (error) { + logger.error('删除用户API Key失败:', error) + res.status(500).json({ + success: false, + message: '删除 API Key 失败' + }) + } +}) + +module.exports = router diff --git a/src/services/apiKeyService.js b/src/services/apiKeyService.js index 46be6352..9986736b 100644 --- a/src/services/apiKeyService.js +++ b/src/services/apiKeyService.js @@ -32,7 +32,9 @@ class ApiKeyService { enableClientRestriction = false, allowedClients = [], dailyCostLimit = 0, - tags = [] + tags = [], + owner = null, + ownerType = null } = options // 生成简单的API Key (64字符十六进制) @@ -66,7 +68,9 @@ class ApiKeyService { createdAt: new Date().toISOString(), lastUsedAt: '', expiresAt: expiresAt || '', - createdBy: 'admin' // 可以根据需要扩展用户系统 + createdBy: 'admin', // 可以根据需要扩展用户系统 + owner: owner || '', + ownerType: ownerType || '' } // 保存API Key数据并建立哈希映射 @@ -99,7 +103,9 @@ class ApiKeyService { tags: JSON.parse(keyData.tags || '[]'), createdAt: keyData.createdAt, expiresAt: keyData.expiresAt, - createdBy: keyData.createdBy + createdBy: keyData.createdBy, + owner: keyData.owner, + ownerType: keyData.ownerType } } @@ -294,11 +300,21 @@ class ApiKeyService { // 📝 更新API Key async updateApiKey(keyId, updates) { try { + logger.debug(`🔧 Updating API key ${keyId} with:`, updates) + const keyData = await redis.getApiKey(keyId) if (!keyData || Object.keys(keyData).length === 0) { + logger.error(`❌ API key not found: ${keyId}`) throw new Error('API key not found') } + logger.debug(`📋 Current API key data:`, { + id: keyData.id, + name: keyData.name, + owner: keyData.owner, + ownerType: keyData.ownerType + }) + // 允许更新的字段 const allowedUpdates = [ 'name', @@ -344,7 +360,10 @@ class ApiKeyService { // 更新时不需要重新建立哈希映射,因为API Key本身没有变化 await redis.setApiKey(keyId, updatedData) - logger.success(`📝 Updated API key: ${keyId}`) + logger.success(`📝 Updated API key: ${keyId}`, { + updatedFields: Object.keys(updates), + newName: updatedData.name + }) return { success: true } } catch (error) { diff --git a/src/services/ldapService.js b/src/services/ldapService.js new file mode 100644 index 00000000..5838f570 --- /dev/null +++ b/src/services/ldapService.js @@ -0,0 +1,761 @@ +const ldap = require('ldapjs') +const logger = require('../utils/logger') + +class LDAPService { + constructor() { + this.client = null + + // 检查必需的LDAP配置 + if ( + !process.env.LDAP_URL || + !process.env.LDAP_BIND_DN || + !process.env.LDAP_BIND_PASSWORD || + !process.env.LDAP_BASE_DN + ) { + logger.warn('⚠️ LDAP配置不完整,请检查.env文件中的LDAP配置项') + } + + this.config = { + url: process.env.LDAP_URL || '', + bindDN: process.env.LDAP_BIND_DN || '', + bindPassword: process.env.LDAP_BIND_PASSWORD || '', + baseDN: process.env.LDAP_BASE_DN || '', + 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/apikeys/EditApiKeyModal.vue b/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue index 7d8069cf..f72b32be 100644 --- a/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue +++ b/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue @@ -33,12 +33,31 @@ >名称 -
名称不可修改
+最多100个字符
+ + ++ 最多500个字符(可选) +
每个用户只能创建一个 API Key
++ API Key 将用于访问 Claude Relay Service +
+ ++ 创建时间:{{ formatDate(apiKey.createdAt) }} +
+今日请求
++ {{ apiKey.usage?.daily?.requests?.toLocaleString() || 0 }} +
+今日Token
++ {{ apiKey.usage?.daily?.tokens?.toLocaleString() || 0 }} +
+今日费用
++ ${{ (apiKey.dailyCost || 0).toFixed(4) }} +
+您的 API 使用情况概览
+API Keys
++ {{ stats.keyCount }} +
+总使用量
++ {{ stats.totalUsage.toLocaleString() }} +
+总额度
++ {{ stats.totalLimit.toLocaleString() }} +
+使用率
++ {{ stats.percentage }}% +
+ID: {{ keyStats.id }}
+使用率
++ {{ keyStats.percentage }}% +
++ 创建 API Key 后开始使用即可查看详细统计 +
+使用您的域账号登录
+