fix(auth): separate DingTalk login credentials from API credentials

This commit is contained in:
WuYongJie 2026-09-23 10:43:16 +08:00
parent fa17ffa9a8
commit 3d22d627fb
11 changed files with 69 additions and 25 deletions

View File

@ -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=

View File

@ -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 成本参考镜像

View File

@ -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=
# 待办跳转回系统的基础地址

View File

@ -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

View File

@ -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<string, unknown> | 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'] }),

View File

@ -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),

View File

@ -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')
})
})

View File

@ -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',
}
})

View File

@ -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
}

View File

@ -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)}`,

View File

@ -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() {