fix(auth): harden DingTalk login network failures
This commit is contained in:
parent
2d4965dfc7
commit
44be8f58b8
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -22,7 +22,7 @@
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"server-only": ["./src/types/server-only-stub.d.ts"]
|
||||
"server-only": ["./src/types/server-only-stub.ts"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user