fix(auth): separate DingTalk login credentials from API credentials
This commit is contained in:
parent
fa17ffa9a8
commit
3d22d627fb
@ -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=
|
||||
|
||||
@ -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 成本参考镜像
|
||||
|
||||
@ -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=
|
||||
|
||||
# 待办跳转回系统的基础地址
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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'] }),
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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',
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)}`,
|
||||
|
||||
@ -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() {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user