修复钉钉工作通知免登凭证混用

This commit is contained in:
chenpei 2026-07-07 09:31:46 +08:00
parent bca088c96d
commit e2d3314e62
8 changed files with 135 additions and 22 deletions

View File

@ -16,7 +16,9 @@ export async function POST(request: NextRequest) {
)
}
const response = await loginDingtalkUserByH5AuthCode(request, code)
const response = await loginDingtalkUserByH5AuthCode(request, code, {
appSource: 'workNotice',
})
response.headers.set('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
return response
} catch (error: any) {

View File

@ -1,5 +1,9 @@
import { NextRequest, NextResponse } from 'next/server'
import { loginDingtalkUserByOAuthCode } from '@/lib/dingtalk-login'
import {
DINGTALK_DIRECT_LOGIN_SOURCE,
parseDingtalkOAuthState,
} from '@/lib/dingtalk-oauth'
/**
* R25修复:移除硬编码的钉钉密钥,强制使用环境变量
@ -11,7 +15,7 @@ import { loginDingtalkUserByOAuthCode } from '@/lib/dingtalk-login'
export async function POST(request: NextRequest) {
try {
const { code } = await request.json()
const { code, state } = await request.json()
if (!code) {
return NextResponse.json(
@ -20,7 +24,10 @@ export async function POST(request: NextRequest) {
)
}
return loginDingtalkUserByOAuthCode(request, String(code))
const { source } = parseDingtalkOAuthState(state)
return loginDingtalkUserByOAuthCode(request, String(code), {
appSource: source === DINGTALK_DIRECT_LOGIN_SOURCE ? 'workNotice' : 'login',
})
} catch (error: any) {
console.error('钉钉登录失败:', error)
return NextResponse.json(

View File

@ -7,8 +7,8 @@ import {
import {
DINGTALK_DIRECT_LOGIN_SOURCE,
buildDingtalkOAuthUrl,
getDingtalkOAuthClientId,
} from '@/lib/dingtalk-oauth'
import { getDingtalkWorkNoticeClientId } from '@/lib/dingtalk-client'
import {
getConfiguredDingtalkCorpId,
normalizeDingtalkCorpId,
@ -45,10 +45,12 @@ function stringifyForInlineScript(value: string) {
function createH5LoginPage({
redirectPath,
corpId,
clientId,
fallbackOAuthUrl,
}: {
redirectPath: string
corpId: string
clientId: string
fallbackOAuthUrl: string
}) {
return new NextResponse(
@ -217,7 +219,7 @@ function createH5LoginPage({
try {
dd.requestAuthCode({
corpId: corpId,
clientId: ${stringifyForInlineScript(getDingtalkOAuthClientId())},
clientId: ${stringifyForInlineScript(clientId)},
onSuccess: function (result) {
var code = resolveAuthCode(result);
if (!code) {
@ -372,6 +374,7 @@ export async function GET(request: NextRequest) {
const fallbackOAuthUrl = buildDingtalkOAuthUrl({
redirectPath,
callbackPath: '/api/dingtalk/callback',
clientId: getDingtalkWorkNoticeClientId(),
source: DINGTALK_DIRECT_LOGIN_SOURCE,
})
@ -391,6 +394,7 @@ export async function GET(request: NextRequest) {
return createH5LoginPage({
redirectPath,
corpId,
clientId: getDingtalkWorkNoticeClientId(),
fallbackOAuthUrl,
})
}

View File

@ -76,6 +76,17 @@ describe('dingtalk client', () => {
}))
})
test('工作通知免登入口优先使用工作通知应用 clientId', async () => {
process.env.DINGTALK_CLIENT_ID = 'login-client-id'
process.env.DINGTALK_CLIENT_SECRET = 'login-client-secret'
process.env.DINGTALK_WORK_NOTICE_CLIENT_ID = 'notice-client-id'
process.env.DINGTALK_WORK_NOTICE_CLIENT_SECRET = 'notice-client-secret'
const { getDingtalkWorkNoticeClientId } = await import('@/lib/dingtalk-client')
expect(getDingtalkWorkNoticeClientId()).toBe('notice-client-id')
})
test('带跳转地址的工作通知优先使用钉钉应用内打开链接', async () => {
process.env.DINGTALK_CLIENT_ID = 'login-client-id'
process.env.DINGTALK_CLIENT_SECRET = 'login-client-secret'

View File

@ -0,0 +1,32 @@
/** @jest-environment node */
describe('dingtalk oauth helpers', () => {
const originalEnv = process.env
beforeEach(() => {
jest.resetModules()
process.env = {
...originalEnv,
NEXT_PUBLIC_APP_URL: 'https://sc.jiansun.vip',
}
})
afterEach(() => {
process.env = originalEnv
jest.restoreAllMocks()
})
test('buildDingtalkOAuthUrl 支持覆盖 client_id', async () => {
process.env.DINGTALK_CLIENT_ID = 'login-client-id'
const { buildDingtalkOAuthUrl } = await import('@/lib/dingtalk-oauth')
const url = new URL(buildDingtalkOAuthUrl({
redirectPath: '/performance-survey',
clientId: 'notice-client-id',
source: 'work_notice_direct',
}))
expect(url.searchParams.get('client_id')).toBe('notice-client-id')
expect(url.searchParams.get('client_id')).not.toBe('login-client-id')
})
})

View File

@ -139,6 +139,20 @@ async function getDingtalkWorkNoticeAccessToken() {
return getDingtalkAccessToken(getDingtalkWorkNoticeAppConfig())
}
export function getDingtalkWorkNoticeClientId() {
return (
process.env.DINGTALK_WORK_NOTICE_CLIENT_ID ||
process.env.DINGTALK_WORK_NOTICE_APP_KEY ||
process.env.DINGTALK_CLIENT_ID ||
process.env.DINGTALK_APP_KEY ||
''
)
}
export async function getDingtalkWorkNoticeAppAccessToken() {
return getDingtalkWorkNoticeAccessToken()
}
async function getDingtalkAccessToken(config: DingtalkAppConfig) {
const cacheKey = `${config.source}:${config.appKey}:${config.appSecret.slice(-8)}`
const cachedAppToken = cachedAppTokens.get(cacheKey)

View File

@ -1,6 +1,9 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getDingtalkAppAccessToken } from '@/lib/dingtalk-client'
import {
getDingtalkAppAccessToken,
getDingtalkWorkNoticeAppAccessToken,
} from '@/lib/dingtalk-client'
import { ensurePerformanceSurveyEmployeeProfileForUser } from '@/lib/performance-survey-report-sync'
import {
getAuthSessionCookieName,
@ -17,16 +20,32 @@ type DingtalkUserInfo = {
email?: string | null
}
type DingtalkLoginAppSource = 'login' | 'workNotice'
let configValidated = false
function validateDingtalkConfig(): { clientId: string; clientSecret: string } | null {
const clientId = process.env.DINGTALK_CLIENT_ID
const clientSecret = process.env.DINGTALK_CLIENT_SECRET
function validateDingtalkConfig(
appSource: DingtalkLoginAppSource = 'login',
): { clientId: string; clientSecret: string } | null {
const clientId = appSource === 'workNotice'
? process.env.DINGTALK_WORK_NOTICE_CLIENT_ID ||
process.env.DINGTALK_WORK_NOTICE_APP_KEY ||
process.env.DINGTALK_CLIENT_ID
: process.env.DINGTALK_CLIENT_ID
const clientSecret = appSource === 'workNotice'
? process.env.DINGTALK_WORK_NOTICE_CLIENT_SECRET ||
process.env.DINGTALK_WORK_NOTICE_APP_SECRET ||
process.env.DINGTALK_CLIENT_SECRET
: process.env.DINGTALK_CLIENT_SECRET
if (!clientId || !clientSecret) {
if (!configValidated) {
console.error('[Dingtalk] 配置错误:缺少必需的环境变量')
console.error('[Dingtalk] 请配置 DINGTALK_CLIENT_ID 和 DINGTALK_CLIENT_SECRET')
console.error(
appSource === 'workNotice'
? '[Dingtalk] 请配置 DINGTALK_WORK_NOTICE_CLIENT_ID 和 DINGTALK_WORK_NOTICE_CLIENT_SECRET'
: '[Dingtalk] 请配置 DINGTALK_CLIENT_ID 和 DINGTALK_CLIENT_SECRET',
)
configValidated = true
}
return null
@ -40,16 +59,22 @@ function validateDingtalkConfig(): { clientId: string; clientSecret: string } |
return { clientId, clientSecret }
}
function getDingtalkConfig(): { clientId: string; clientSecret: string } {
const config = validateDingtalkConfig()
function getDingtalkConfig(
appSource: DingtalkLoginAppSource = 'login',
): { clientId: string; clientSecret: string } {
const config = validateDingtalkConfig(appSource)
if (!config) {
throw new Error('钉钉登录未配置,请联系管理员配置 DINGTALK_CLIENT_ID 和 DINGTALK_CLIENT_SECRET')
throw new Error(
appSource === 'workNotice'
? '钉钉工作通知免登未配置,请联系管理员配置 DINGTALK_WORK_NOTICE_CLIENT_ID 和 DINGTALK_WORK_NOTICE_CLIENT_SECRET'
: '钉钉登录未配置,请联系管理员配置 DINGTALK_CLIENT_ID 和 DINGTALK_CLIENT_SECRET',
)
}
return config
}
async function getUserAccessToken(code: string) {
const config = getDingtalkConfig()
async function getUserAccessToken(code: string, appSource: DingtalkLoginAppSource = 'login') {
const config = getDingtalkConfig(appSource)
const response = await fetch('https://api.dingtalk.com/v1.0/oauth2/userAccessToken', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@ -422,10 +447,21 @@ async function createOrUpdateUserSession(
})
}
export async function loginDingtalkUserByOAuthCode(request: NextRequest, code: string) {
console.log('开始钉钉OAuth登录流程')
function getAppAccessToken(appSource: DingtalkLoginAppSource) {
return appSource === 'workNotice'
? getDingtalkWorkNoticeAppAccessToken()
: getDingtalkAppAccessToken()
}
const tokenData = await getUserAccessToken(code)
export async function loginDingtalkUserByOAuthCode(
request: NextRequest,
code: string,
options?: { appSource?: DingtalkLoginAppSource },
) {
console.log('开始钉钉OAuth登录流程')
const appSource = options?.appSource || 'login'
const tokenData = await getUserAccessToken(code, appSource)
if (!tokenData?.accessToken) {
console.error('获取用户访问令牌返回数据异常:', tokenData)
return NextResponse.json(
@ -435,7 +471,7 @@ export async function loginDingtalkUserByOAuthCode(request: NextRequest, code: s
}
console.log('获取用户访问令牌成功')
const appAccessToken = await getDingtalkAppAccessToken()
const appAccessToken = await getAppAccessToken(appSource)
console.log('获取应用访问令牌成功')
const unionId = await getUnionId(tokenData.accessToken)
@ -450,10 +486,15 @@ export async function loginDingtalkUserByOAuthCode(request: NextRequest, code: s
return createOrUpdateUserSession(request, userId, userInfo)
}
export async function loginDingtalkUserByH5AuthCode(request: NextRequest, code: string) {
export async function loginDingtalkUserByH5AuthCode(
request: NextRequest,
code: string,
options?: { appSource?: DingtalkLoginAppSource },
) {
console.log('开始钉钉H5免登流程')
const appSource = options?.appSource || 'login'
const appAccessToken = await getDingtalkAppAccessToken()
const appAccessToken = await getAppAccessToken(appSource)
console.log('获取应用访问令牌成功')
const userId = await getUserIdByH5AuthCode(code, appAccessToken)

View File

@ -65,11 +65,13 @@ export function parseDingtalkOAuthState(value: unknown) {
export function buildDingtalkOAuthUrl({
redirectPath,
callbackPath = '/api/dingtalk/callback',
clientId,
prompt,
source,
}: {
redirectPath: unknown
callbackPath?: string
clientId?: string
prompt?: string
source?: string
}) {
@ -77,7 +79,7 @@ export function buildDingtalkOAuthUrl({
const redirectUrl = `${publicOrigin}${callbackPath}`
const params = new URLSearchParams({
redirect_uri: redirectUrl,
client_id: getDingtalkOAuthClientId(),
client_id: clientId || getDingtalkOAuthClientId(),
scope: 'openid',
response_type: 'code',
state: encodeDingtalkOAuthState(redirectPath, publicOrigin, { source }),