fix(auth): harden DingTalk login network failures

This commit is contained in:
WuYongJie 2026-09-24 09:50:38 +08:00
parent 2d4965dfc7
commit 44be8f58b8
5 changed files with 199 additions and 15 deletions

View File

@ -24,6 +24,7 @@ jest.mock('@/server/services/dingtalk-auth.service', () => ({
}))
const { GET } = require('./route')
const { AppError, ErrorCode } = require('@/server/core/errors')
function createMockRequest(url: string) {
return {
@ -56,6 +57,10 @@ describe('GET /api/auth/dingtalk/callback', () => {
)
})
afterEach(() => {
jest.restoreAllMocks()
})
it('应支持官方回调里的 authCode 参数', async () => {
const response = await GET(
createMockRequest('http://180.97.215.57:5246/api/auth/dingtalk/callback?authCode=auth-code-1&state=state-1'),
@ -79,7 +84,10 @@ describe('GET /api/auth/dingtalk/callback', () => {
})
it('待激活账号应回跳登录页并提示管理员激活', async () => {
mockAuthenticate.mockRejectedValue(new Error('账号待管理员激活'))
jest.spyOn(console, 'error').mockImplementation(() => undefined)
mockAuthenticate.mockRejectedValue(
new AppError(ErrorCode.AUTH_PENDING_ACTIVATION, '账号待管理员激活'),
)
const response = await GET(
createMockRequest('https://cp.jiansun.vip/api/auth/dingtalk/callback?authCode=auth-code-2&state=state-2'),
@ -90,4 +98,21 @@ describe('GET /api/auth/dingtalk/callback', () => {
'https://cp.jiansun.vip/login?dingtalkError=%E8%B4%A6%E5%8F%B7%E5%BE%85%E7%AE%A1%E7%90%86%E5%91%98%E6%BF%80%E6%B4%BB',
)
})
it('未知底层错误不应原样显示或写入敏感日志', async () => {
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined)
const rawError = Object.assign(new TypeError('fetch failed'), {
config: { data: { clientSecret: 'must-not-appear' } },
})
mockAuthenticate.mockRejectedValue(rawError)
const response = await GET(
createMockRequest('https://cp.jiansun.vip/api/auth/dingtalk/callback?authCode=auth-code-3&state=state-3'),
)
const redirectUrl = new URL(response.headers.get('location')!)
expect(redirectUrl.searchParams.get('dingtalkError')).toBe('钉钉登录失败,请稍后重试')
expect(JSON.stringify(consoleError.mock.calls)).not.toContain('must-not-appear')
expect(JSON.stringify(consoleError.mock.calls)).not.toContain('fetch failed')
})
})

View File

@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'
import { getAuthTokenCookieOptions } from '@/server/auth/auth-cookie'
import { isAppError } from '@/server/core/errors'
import { resolveWorkspaceRedirectForUser } from '@/lib/workspace-route-permissions'
import {
dingtalkAuthService,
@ -15,6 +16,33 @@ function buildLoginRedirect(request: NextRequest, message?: string) {
return url
}
function getSafeDingtalkCallbackLog(error: unknown) {
if (isAppError(error)) {
const integration = error.details?.integration === 'dingtalk' ? 'dingtalk' : undefined
const operation = typeof error.details?.operation === 'string' ? error.details.operation : undefined
const reasonCode = typeof error.details?.reasonCode === 'string' ? error.details.reasonCode : undefined
return {
name: error.name,
code: error.code,
statusCode: error.statusCode,
message: error.message,
integration,
operation,
reasonCode,
}
}
const rawCode = error && typeof error === 'object' && 'code' in error && typeof error.code === 'string'
? error.code
: ''
return {
name: error instanceof Error ? error.name : 'UnknownError',
code: /^[A-Z0-9_:-]{1,64}$/.test(rawCode) ? rawCode : undefined,
}
}
export async function GET(request: NextRequest) {
const code = request.nextUrl.searchParams.get('authCode') || request.nextUrl.searchParams.get('code')
const state = request.nextUrl.searchParams.get('state')
@ -43,7 +71,8 @@ export async function GET(request: NextRequest) {
return response
} catch (error) {
const message = error instanceof Error ? error.message : '钉钉登录失败,请稍后重试'
console.error('[DingtalkAuth] 回调处理失败', getSafeDingtalkCallbackLog(error))
const message = isAppError(error) ? error.message : '钉钉登录失败,请稍后重试'
return NextResponse.redirect(buildLoginRedirect(request, message))
}
}

View File

@ -1,4 +1,6 @@
import { afterAll, beforeEach, describe, expect, it, jest } from '@jest/globals'
import { afterAll, afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'
jest.mock('server-only', () => ({}))
jest.mock('@/lib/prisma', () => ({
prisma: {
@ -22,10 +24,16 @@ jest.mock('../auth-redirect.service', () => ({
}))
const originalEnv = process.env
const { dingtalkAuthService, resolveDingtalkCallbackOrigin } = require('../dingtalk-auth.service')
const originalFetch = global.fetch
const {
DINGTALK_TEMPORARILY_UNAVAILABLE_MESSAGE,
dingtalkAuthService,
resolveDingtalkCallbackOrigin,
} = require('../dingtalk-auth.service')
describe('dingtalk-auth.service', () => {
beforeEach(() => {
jest.clearAllMocks()
process.env = { ...originalEnv }
delete process.env.DINGTALK_CALLBACK_ORIGIN
delete process.env.DINGTALK_LOGIN_CLIENT_ID
@ -37,6 +45,11 @@ describe('dingtalk-auth.service', () => {
process.env.JWT_SECRET = 'test-jwt-secret'
})
afterEach(() => {
jest.restoreAllMocks()
global.fetch = originalFetch
})
afterAll(() => {
process.env = originalEnv
})
@ -84,4 +97,24 @@ describe('dingtalk-auth.service', () => {
dingtalkAuthService.buildAuthorizationUrl({ requestOrigin: 'https://request.local' }),
).toThrow('DINGTALK_LOGIN_CLIENT_ID')
})
it('钉钉网络超时时应重试并返回可理解的中文提示', async () => {
process.env.DINGTALK_LOGIN_CLIENT_ID = 'login-client-id'
process.env.DINGTALK_LOGIN_CLIENT_SECRET = 'login-client-secret'
const consoleWarn = jest.spyOn(console, 'warn').mockImplementation(() => undefined)
const networkError = new TypeError('fetch failed')
Object.defineProperty(networkError, 'cause', {
value: Object.assign(new Error('connect timeout'), { code: 'ETIMEDOUT' }),
})
global.fetch = jest.fn(async () => {
throw networkError
}) as typeof fetch
await expect(dingtalkAuthService.authenticate('auth-code')).rejects.toMatchObject({
message: DINGTALK_TEMPORARILY_UNAVAILABLE_MESSAGE,
})
expect(global.fetch).toHaveBeenCalledTimes(3)
expect(JSON.stringify(consoleWarn.mock.calls)).toContain('ETIMEDOUT')
expect(JSON.stringify(consoleWarn.mock.calls)).not.toContain('login-client-secret')
})
})

View File

@ -18,6 +18,20 @@ const DINGTALK_USER_BY_UNION_ID_URL = 'https://oapi.dingtalk.com/topapi/user/get
const DINGTALK_USER_INFO_URL = 'https://oapi.dingtalk.com/topapi/v2/user/get'
const STATE_TTL_MS = 10 * 60 * 1000
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const DINGTALK_REQUEST_TIMEOUT_MS = process.env.NODE_ENV === 'test' ? 1_000 : 10_000
const DINGTALK_REQUEST_RETRY_DELAYS_MS = process.env.NODE_ENV === 'test' ? [0, 0] : [300, 1_200]
const RETRYABLE_DINGTALK_ERROR_CODES = new Set([
'ABORT_ERR',
'ECONNABORTED',
'ECONNRESET',
'EAI_AGAIN',
'EHOSTUNREACH',
'ENETUNREACH',
'ETIMEDOUT',
'UND_ERR_CONNECT_TIMEOUT',
])
export const DINGTALK_TEMPORARILY_UNAVAILABLE_MESSAGE = '钉钉服务暂时连接不上,请稍后重新登录'
interface DingtalkStatePayload {
redirectPath: string
@ -33,6 +47,92 @@ interface DingtalkProfile {
mobile?: string | null
}
function getDingtalkRequestErrorCode(error: unknown): string {
if (!error || typeof error !== 'object') return ''
const directCode = 'code' in error && typeof error.code === 'string' ? error.code : ''
if (directCode) return directCode
const cause = 'cause' in error && error.cause && typeof error.cause === 'object'
? error.cause
: null
return cause && 'code' in cause && typeof cause.code === 'string' ? cause.code : ''
}
function isRetryableDingtalkRequestError(error: unknown): boolean {
const name = error instanceof Error ? error.name : ''
return error instanceof TypeError
|| name === 'AbortError'
|| name === 'TimeoutError'
|| RETRYABLE_DINGTALK_ERROR_CODES.has(getDingtalkRequestErrorCode(error))
}
function wait(durationMs: number) {
return new Promise((resolve) => setTimeout(resolve, durationMs))
}
async function requestDingtalk(
operation: string,
input: string | URL,
init: RequestInit,
): Promise<Response> {
const maxAttempts = DINGTALK_REQUEST_RETRY_DELAYS_MS.length + 1
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
const response = await fetch(input, {
...init,
cache: 'no-store',
signal: AbortSignal.timeout(DINGTALK_REQUEST_TIMEOUT_MS),
})
const retryableStatus = response.status === 429 || response.status >= 500
if (!retryableStatus) return response
const reasonCode = `HTTP_${response.status}`
await response.body?.cancel().catch(() => undefined)
if (attempt >= maxAttempts) {
throw new AppError(
ErrorCode.SYSTEM_INTERNAL,
DINGTALK_TEMPORARILY_UNAVAILABLE_MESSAGE,
{ integration: 'dingtalk', operation, reasonCode },
)
}
console.warn('[DingtalkAuth] 请求异常,准备重试', {
operation,
attempt,
maxAttempts,
reasonCode,
})
} catch (error) {
if (error instanceof AppError) throw error
if (!isRetryableDingtalkRequestError(error)) throw error
const reasonCode = getDingtalkRequestErrorCode(error)
|| (error instanceof Error ? error.name : '')
|| 'NETWORK_ERROR'
if (attempt >= maxAttempts) {
throw new AppError(
ErrorCode.SYSTEM_INTERNAL,
DINGTALK_TEMPORARILY_UNAVAILABLE_MESSAGE,
{ integration: 'dingtalk', operation, reasonCode },
)
}
console.warn('[DingtalkAuth] 请求异常,准备重试', {
operation,
attempt,
maxAttempts,
reasonCode,
})
}
await wait(DINGTALK_REQUEST_RETRY_DELAYS_MS[attempt - 1])
}
throw new AppError(ErrorCode.SYSTEM_INTERNAL, DINGTALK_TEMPORARILY_UNAVAILABLE_MESSAGE)
}
export function resolveDingtalkCallbackOrigin(requestOrigin: string): string {
const configuredOrigin = process.env.DINGTALK_CALLBACK_ORIGIN?.trim()
if (configuredOrigin) {
@ -175,7 +275,7 @@ class DingtalkAuthService {
}
private async getUserAccessToken(code: string): Promise<string> {
const response = await fetch(DINGTALK_USER_ACCESS_TOKEN_URL, {
const response = await requestDingtalk('获取用户令牌', DINGTALK_USER_ACCESS_TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@ -184,7 +284,6 @@ class DingtalkAuthService {
code,
grantType: 'authorization_code',
}),
cache: 'no-store',
})
const data = await response.json().catch(() => null)
@ -199,11 +298,10 @@ class DingtalkAuthService {
}
private async getUnionId(userAccessToken: string): Promise<string> {
const response = await fetch(DINGTALK_CONTACT_ME_URL, {
const response = await requestDingtalk('获取用户身份', DINGTALK_CONTACT_ME_URL, {
headers: {
'x-acs-dingtalk-access-token': userAccessToken,
},
cache: 'no-store',
})
const data = await response.json().catch(() => null)
@ -215,9 +313,10 @@ class DingtalkAuthService {
}
private async getAppAccessToken(): Promise<string> {
const response = await fetch(
const response = await requestDingtalk(
'获取应用令牌',
`${DINGTALK_APP_ACCESS_TOKEN_URL}?appkey=${encodeURIComponent(this.getClientId())}&appsecret=${encodeURIComponent(this.getClientSecret())}`,
{ cache: 'no-store' }
{},
)
const data = await response.json().catch(() => null)
@ -229,11 +328,10 @@ class DingtalkAuthService {
}
private async getUserIdByUnionId(unionId: string, appAccessToken: string): Promise<string> {
const response = await fetch(`${DINGTALK_USER_BY_UNION_ID_URL}?access_token=${encodeURIComponent(appAccessToken)}`, {
const response = await requestDingtalk('获取用户编号', `${DINGTALK_USER_BY_UNION_ID_URL}?access_token=${encodeURIComponent(appAccessToken)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ unionid: unionId }),
cache: 'no-store',
})
const data = await response.json().catch(() => null)
@ -245,11 +343,10 @@ class DingtalkAuthService {
}
private async getUserInfo(userId: string, appAccessToken: string): Promise<Record<string, unknown>> {
const response = await fetch(`${DINGTALK_USER_INFO_URL}?access_token=${encodeURIComponent(appAccessToken)}`, {
const response = await requestDingtalk('获取用户详情', `${DINGTALK_USER_INFO_URL}?access_token=${encodeURIComponent(appAccessToken)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userid: userId }),
cache: 'no-store',
})
const data = await response.json().catch(() => null)

View File

@ -22,7 +22,7 @@
],
"paths": {
"@/*": ["./src/*"],
"server-only": ["./src/types/server-only-stub.d.ts"]
"server-only": ["./src/types/server-only-stub.ts"]
}
},
"include": [