From 3d22d627fb707b8977968c561ed75364b127e31c Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Wed, 23 Sep 2026 10:43:16 +0800 Subject: [PATCH] fix(auth): separate DingTalk login credentials from API credentials --- furniture-3d-system/.env.example | 10 +++++-- .../docs/workflow/生产部署入口说明.md | 2 +- .../OA看板-钉钉待办接入方案-20260729.md | 10 +++++-- .../install-dingtalk-directory-sync-timer.sh | 4 +-- .../dingtalk-directory/client.test.ts | 16 +++++++++-- .../integrations/dingtalk-directory/client.ts | 4 +-- .../__tests__/dingtalk-auth.service.test.ts | 28 ++++++++++++++++++- .../__tests__/dingtalk-todo.service.test.ts | 4 +-- .../server/services/dingtalk-auth.service.ts | 8 +++--- .../dingtalk-process-instance.service.ts | 4 +-- .../server/services/dingtalk-todo.service.ts | 4 +-- 11 files changed, 69 insertions(+), 25 deletions(-) diff --git a/furniture-3d-system/.env.example b/furniture-3d-system/.env.example index db8d5a7a..e470b06c 100644 --- a/furniture-3d-system/.env.example +++ b/furniture-3d-system/.env.example @@ -74,11 +74,15 @@ JST_PLANNING_SQLSERVER_TRUST_SERVER_CERTIFICATE=true JWT_SECRET=replace-with-random-secret JWT_EXPIRES_IN=7d -# 钉钉登录 -DINGTALK_CLIENT_ID= -DINGTALK_CLIENT_SECRET= +# 钉钉登录(OAuth 应用;只用于登录授权和用户信息获取) +DINGTALK_LOGIN_CLIENT_ID= +DINGTALK_LOGIN_CLIENT_SECRET= DINGTALK_CALLBACK_ORIGIN= +# 钉钉开放平台 API(通讯录、待办、审批等服务端调用) +DINGTALK_API_CLIENT_ID= +DINGTALK_API_CLIENT_SECRET= + # 钉钉待办:OA 阶段超时预警,以及预计出货日前 15 天的品牌策划主管提醒。 DINGTALK_TODO_ENABLED=false DINGTALK_TODO_DETAIL_BASE_URL= diff --git a/furniture-3d-system/docs/workflow/生产部署入口说明.md b/furniture-3d-system/docs/workflow/生产部署入口说明.md index 985e66dc..d376a3ef 100644 --- a/furniture-3d-system/docs/workflow/生产部署入口说明.md +++ b/furniture-3d-system/docs/workflow/生产部署入口说明.md @@ -35,7 +35,7 @@ - 源库连接信息只允许放在生产本地 `/opt/furniture-3d-system-prod/shared/employee-directory-sync.env`,不能写入 Git 或共享文档。 - 生产应用内部接口密钥继续从 `/opt/furniture-3d-system-prod/shared/.env` 的 `HR_SYNC_SECRET` 读取。 - 人员信息表的钉钉组织同步使用 `furniture-3d-system/scripts/install-dingtalk-directory-sync-timer.sh`,生产 systemd 名称为 `furniture-dingtalk-directory-sync.service` / `furniture-dingtalk-directory-sync.timer`。 -- 钉钉同步每天 `03:00` 执行一次,读取生产共享 `.env` 中的 `DINGTALK_CLIENT_ID` 和 `DINGTALK_CLIENT_SECRET`,并自动更新人员信息;正式页面仍保持只读。 +- 钉钉同步每天 `03:00` 执行一次,读取生产共享 `.env` 中的 `DINGTALK_API_CLIENT_ID` 和 `DINGTALK_API_CLIENT_SECRET`,并自动更新人员信息;正式页面仍保持只读。钉钉网页登录使用独立的 `DINGTALK_LOGIN_CLIENT_ID`、`DINGTALK_LOGIN_CLIENT_SECRET`,两组凭据不能混用。 - 首次部署后需在正式服务器执行该安装脚本,并手动执行一次 `npm run sync:dingtalk-directory:apply` 完成首次填充;后续由 systemd 每日执行。 ## BOM 成本参考镜像 diff --git a/furniture-3d-system/docs/架构设计/OA看板-钉钉待办接入方案-20260729.md b/furniture-3d-system/docs/架构设计/OA看板-钉钉待办接入方案-20260729.md index e72922d0..2cca71c5 100644 --- a/furniture-3d-system/docs/架构设计/OA看板-钉钉待办接入方案-20260729.md +++ b/furniture-3d-system/docs/架构设计/OA看板-钉钉待办接入方案-20260729.md @@ -78,9 +78,13 @@ # 是否启用 OA 看板钉钉待办 DINGTALK_TODO_ENABLED=false -# 钉钉应用基础配置 -DINGTALK_CLIENT_ID= -DINGTALK_CLIENT_SECRET= +# 钉钉开放平台 API 应用配置(待办、审批等服务端调用) +DINGTALK_API_CLIENT_ID= +DINGTALK_API_CLIENT_SECRET= + +# 钉钉网页登录使用独立 OAuth 应用配置,不与 API 应用混用 +DINGTALK_LOGIN_CLIENT_ID= +DINGTALK_LOGIN_CLIENT_SECRET= DINGTALK_CALLBACK_ORIGIN= # 待办跳转回系统的基础地址 diff --git a/furniture-3d-system/scripts/install-dingtalk-directory-sync-timer.sh b/furniture-3d-system/scripts/install-dingtalk-directory-sync-timer.sh index 4e883c37..7ccf0e91 100755 --- a/furniture-3d-system/scripts/install-dingtalk-directory-sync-timer.sh +++ b/furniture-3d-system/scripts/install-dingtalk-directory-sync-timer.sh @@ -24,8 +24,8 @@ if [ ! -f "$APP_ENV" ]; then exit 1 fi -if ! grep -Eq '^DINGTALK_CLIENT_ID=.+$' "$APP_ENV" || ! grep -Eq '^DINGTALK_CLIENT_SECRET=.+$' "$APP_ENV"; then - echo "DINGTALK_CLIENT_ID and DINGTALK_CLIENT_SECRET must be configured in $APP_ENV." >&2 +if ! grep -Eq '^DINGTALK_API_CLIENT_ID=.+$' "$APP_ENV" || ! grep -Eq '^DINGTALK_API_CLIENT_SECRET=.+$' "$APP_ENV"; then + echo "DINGTALK_API_CLIENT_ID and DINGTALK_API_CLIENT_SECRET must be configured in $APP_ENV." >&2 exit 1 fi diff --git a/furniture-3d-system/src/server/integrations/dingtalk-directory/client.test.ts b/furniture-3d-system/src/server/integrations/dingtalk-directory/client.test.ts index b5680497..744d01b3 100644 --- a/furniture-3d-system/src/server/integrations/dingtalk-directory/client.test.ts +++ b/furniture-3d-system/src/server/integrations/dingtalk-directory/client.test.ts @@ -14,8 +14,10 @@ function emptyRoleListResponse(url: string) { describe('DingtalkDirectoryClient', () => { beforeEach(() => { - process.env.DINGTALK_CLIENT_ID = 'test-client-id' - process.env.DINGTALK_CLIENT_SECRET = 'test-client-secret' + process.env.DINGTALK_API_CLIENT_ID = 'test-api-client-id' + process.env.DINGTALK_API_CLIENT_SECRET = 'test-api-client-secret' + process.env.DINGTALK_LOGIN_CLIENT_ID = 'test-login-client-id' + process.env.DINGTALK_LOGIN_CLIENT_SECRET = 'test-login-client-secret' }) afterEach(() => { @@ -23,10 +25,14 @@ describe('DingtalkDirectoryClient', () => { }) it('遍历部门并提取员工直属主管和部门主管', async () => { + let accessTokenRequestBody: Record | null = null global.fetch = jest.fn(async (input, init) => { const url = String(input) const body = init?.body ? JSON.parse(String(init.body)) : {} - if (url.includes('/oauth2/accessToken')) return response({ accessToken: 'token' }) + if (url.includes('/oauth2/accessToken')) { + accessTokenRequestBody = body + return response({ accessToken: 'token' }) + } if (url.includes('/department/get')) { return response({ errcode: 0, result: body.dept_id === 2 ? { name: '运营部', parent_id: 1, order: 2, org_dept_owner: 'manager-1' } @@ -68,6 +74,10 @@ describe('DingtalkDirectoryClient', () => { const result = await dingtalkDirectoryClient.fetchSnapshot() + expect(accessTokenRequestBody).toEqual({ + appKey: 'test-api-client-id', + appSecret: 'test-api-client-secret', + }) expect(result.departments).toEqual([ expect.objectContaining({ externalId: '1', name: '集团', parentExternalId: null }), expect.objectContaining({ externalId: '2', name: '运营部', parentExternalId: '1', managerDingtalkUserIds: ['manager-1'] }), diff --git a/furniture-3d-system/src/server/integrations/dingtalk-directory/client.ts b/furniture-3d-system/src/server/integrations/dingtalk-directory/client.ts index 8b7ab679..59e6027c 100644 --- a/furniture-3d-system/src/server/integrations/dingtalk-directory/client.ts +++ b/furniture-3d-system/src/server/integrations/dingtalk-directory/client.ts @@ -320,8 +320,8 @@ class DingtalkDirectoryClient { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - appKey: requiredEnv('DINGTALK_CLIENT_ID'), - appSecret: requiredEnv('DINGTALK_CLIENT_SECRET'), + appKey: requiredEnv('DINGTALK_API_CLIENT_ID'), + appSecret: requiredEnv('DINGTALK_API_CLIENT_SECRET'), }), cache: 'no-store', signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), diff --git a/furniture-3d-system/src/server/services/__tests__/dingtalk-auth.service.test.ts b/furniture-3d-system/src/server/services/__tests__/dingtalk-auth.service.test.ts index 491d1abd..e600cdf6 100644 --- a/furniture-3d-system/src/server/services/__tests__/dingtalk-auth.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/dingtalk-auth.service.test.ts @@ -22,14 +22,19 @@ jest.mock('../auth-redirect.service', () => ({ })) const originalEnv = process.env -const { resolveDingtalkCallbackOrigin } = require('../dingtalk-auth.service') +const { dingtalkAuthService, resolveDingtalkCallbackOrigin } = require('../dingtalk-auth.service') describe('dingtalk-auth.service', () => { beforeEach(() => { process.env = { ...originalEnv } delete process.env.DINGTALK_CALLBACK_ORIGIN + delete process.env.DINGTALK_LOGIN_CLIENT_ID + delete process.env.DINGTALK_LOGIN_CLIENT_SECRET + delete process.env.DINGTALK_CLIENT_ID + delete process.env.DINGTALK_CLIENT_SECRET delete process.env.NEXT_PUBLIC_APP_URL delete process.env.NEXTAUTH_URL + process.env.JWT_SECRET = 'test-jwt-secret' }) afterAll(() => { @@ -58,4 +63,25 @@ describe('dingtalk-auth.service', () => { it('所有配置都缺失时应回退到当前请求地址', () => { expect(resolveDingtalkCallbackOrigin('http://request.local/')).toBe('http://request.local') }) + + it('登录授权地址只使用登录专用应用凭据', () => { + process.env.DINGTALK_CALLBACK_ORIGIN = 'https://cp.jiansun.vip' + process.env.DINGTALK_LOGIN_CLIENT_ID = 'login-client-id' + process.env.DINGTALK_API_CLIENT_ID = 'api-client-id' + + const authorizationUrl = new URL( + dingtalkAuthService.buildAuthorizationUrl({ requestOrigin: 'https://request.local' }), + ) + + expect(authorizationUrl.searchParams.get('client_id')).toBe('login-client-id') + expect(authorizationUrl.searchParams.get('redirect_uri')).toBe( + 'https://cp.jiansun.vip/api/auth/dingtalk/callback', + ) + }) + + it('缺少登录专用 AppKey 时应明确提示配置项', () => { + expect(() => + dingtalkAuthService.buildAuthorizationUrl({ requestOrigin: 'https://request.local' }), + ).toThrow('DINGTALK_LOGIN_CLIENT_ID') + }) }) diff --git a/furniture-3d-system/src/server/services/__tests__/dingtalk-todo.service.test.ts b/furniture-3d-system/src/server/services/__tests__/dingtalk-todo.service.test.ts index e4674fe0..cfc4b329 100644 --- a/furniture-3d-system/src/server/services/__tests__/dingtalk-todo.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/dingtalk-todo.service.test.ts @@ -8,8 +8,8 @@ describe('dingtalk-todo.service', () => { beforeEach(() => { process.env = { ...originalEnv, - DINGTALK_CLIENT_ID: 'test-client-id', - DINGTALK_CLIENT_SECRET: 'test-client-secret', + DINGTALK_API_CLIENT_ID: 'test-api-client-id', + DINGTALK_API_CLIENT_SECRET: 'test-api-client-secret', } }) diff --git a/furniture-3d-system/src/server/services/dingtalk-auth.service.ts b/furniture-3d-system/src/server/services/dingtalk-auth.service.ts index 13b0af2a..c080c20a 100644 --- a/furniture-3d-system/src/server/services/dingtalk-auth.service.ts +++ b/furniture-3d-system/src/server/services/dingtalk-auth.service.ts @@ -94,17 +94,17 @@ class DingtalkAuthService { } private getClientId(): string { - const clientId = process.env.DINGTALK_CLIENT_ID?.trim() + const clientId = process.env.DINGTALK_LOGIN_CLIENT_ID?.trim() if (!clientId) { - throw new AppError(ErrorCode.SYSTEM_INTERNAL, '钉钉登录未配置 AppKey') + throw new AppError(ErrorCode.SYSTEM_INTERNAL, '钉钉登录未配置 DINGTALK_LOGIN_CLIENT_ID') } return clientId } private getClientSecret(): string { - const clientSecret = process.env.DINGTALK_CLIENT_SECRET?.trim() + const clientSecret = process.env.DINGTALK_LOGIN_CLIENT_SECRET?.trim() if (!clientSecret) { - throw new AppError(ErrorCode.SYSTEM_INTERNAL, '钉钉登录未配置 AppSecret') + throw new AppError(ErrorCode.SYSTEM_INTERNAL, '钉钉登录未配置 DINGTALK_LOGIN_CLIENT_SECRET') } return clientSecret } diff --git a/furniture-3d-system/src/server/services/dingtalk-process-instance.service.ts b/furniture-3d-system/src/server/services/dingtalk-process-instance.service.ts index 2e0afcec..19a275b9 100644 --- a/furniture-3d-system/src/server/services/dingtalk-process-instance.service.ts +++ b/furniture-3d-system/src/server/services/dingtalk-process-instance.service.ts @@ -92,8 +92,8 @@ class DingtalkProcessInstanceService { if (this.tokenPromise) return this.tokenPromise this.tokenPromise = (async () => { - const appKey = trim(process.env.DINGTALK_CLIENT_ID) - const appSecret = trim(process.env.DINGTALK_CLIENT_SECRET) + const appKey = trim(process.env.DINGTALK_API_CLIENT_ID) + const appSecret = trim(process.env.DINGTALK_API_CLIENT_SECRET) if (!appKey || !appSecret) throw new Error('钉钉应用凭据未配置') const response = await fetch( `${APP_TOKEN_URL}?appkey=${encodeURIComponent(appKey)}&appsecret=${encodeURIComponent(appSecret)}`, diff --git a/furniture-3d-system/src/server/services/dingtalk-todo.service.ts b/furniture-3d-system/src/server/services/dingtalk-todo.service.ts index 86ce0701..161fed2a 100644 --- a/furniture-3d-system/src/server/services/dingtalk-todo.service.ts +++ b/furniture-3d-system/src/server/services/dingtalk-todo.service.ts @@ -85,11 +85,11 @@ class DingtalkTodoService { } private getClientId() { - return requireEnv('DINGTALK_CLIENT_ID', trimToNull(process.env.DINGTALK_CLIENT_ID)) + return requireEnv('DINGTALK_API_CLIENT_ID', trimToNull(process.env.DINGTALK_API_CLIENT_ID)) } private getClientSecret() { - return requireEnv('DINGTALK_CLIENT_SECRET', trimToNull(process.env.DINGTALK_CLIENT_SECRET)) + return requireEnv('DINGTALK_API_CLIENT_SECRET', trimToNull(process.env.DINGTALK_API_CLIENT_SECRET)) } private async getAppAccessToken() {