diff --git a/app/api/dingtalk/h5-login/route.test.ts b/app/api/dingtalk/h5-login/route.test.ts index fd83002..6c22146 100644 --- a/app/api/dingtalk/h5-login/route.test.ts +++ b/app/api/dingtalk/h5-login/route.test.ts @@ -127,11 +127,26 @@ describe('DingTalk H5 public login body limit', () => { )) expect(response.status).toBe(200) + const setCookies = (response.headers as Headers & { + getSetCookie?: () => string[] + }).getSetCookie?.() || [] + expect(setCookies).toHaveLength(3) + expect(setCookies).toEqual(expect.arrayContaining([ + expect.stringContaining('dingtalk-performance-session=signed-performance-token; Path=/performance-survey'), + expect.stringContaining('dingtalk-performance-session-api=signed-performance-token; Path=/api'), + expect.stringContaining('dingtalk-performance-session-active=1; Path=/performance-survey'), + ])) expect(response.headers.get('set-cookie')).toContain( 'dingtalk-performance-session=signed-performance-token', ) expect(response.headers.get('set-cookie')).toContain('Path=/performance-survey') + expect(response.headers.get('set-cookie')).toContain( + 'dingtalk-performance-session-api=signed-performance-token', + ) expect(response.headers.get('set-cookie')).toContain('Path=/api') + expect(response.headers.get('set-cookie')).toContain( + 'dingtalk-performance-session-active=1', + ) expect(response.headers.get('set-cookie')).toContain('HttpOnly') expect(authorizeDingtalkH5UserForNextAuth).toHaveBeenCalledWith('short-code', { appSource: 'workNotice', diff --git a/app/api/dingtalk/h5-login/route.ts b/app/api/dingtalk/h5-login/route.ts index b39ee07..6a45f27 100644 --- a/app/api/dingtalk/h5-login/route.ts +++ b/app/api/dingtalk/h5-login/route.ts @@ -7,7 +7,11 @@ import { createDingtalkPerformanceSessionToken, resolveDingtalkPerformanceSessionScope, } from '@/lib/dingtalk-performance-session' -import { DINGTALK_PERFORMANCE_SESSION_COOKIE } from '@/lib/dingtalk-performance-session-constants' +import { + DINGTALK_PERFORMANCE_SESSION_API_COOKIE, + DINGTALK_PERFORMANCE_SESSION_COOKIE, + DINGTALK_PERFORMANCE_SESSION_HINT_COOKIE, +} from '@/lib/dingtalk-performance-session-constants' import { getPublicAppOrigin } from '@/lib/public-app-url' import { normalizeInternalRedirectPath } from '@/lib/safe-redirect' import { resolveSecureAuthCookieFromRequest } from '@/lib/auth-cookie' @@ -297,13 +301,20 @@ ${SURVEY_OPENING_PAGE_MARKUP} path: '/performance-survey', maxAge: 12 * 60 * 60, }) - response.cookies.set(DINGTALK_PERFORMANCE_SESSION_COOKIE, token, { + response.cookies.set(DINGTALK_PERFORMANCE_SESSION_API_COOKIE, token, { httpOnly: true, sameSite: 'lax', secure: resolveSecureAuthCookieFromRequest(request), path: '/api', maxAge: 12 * 60 * 60, }) + response.cookies.set(DINGTALK_PERFORMANCE_SESSION_HINT_COOKIE, '1', { + httpOnly: false, + sameSite: 'lax', + secure: resolveSecureAuthCookieFromRequest(request), + path: '/performance-survey', + maxAge: 12 * 60 * 60, + }) return response } diff --git a/app/api/performance-survey/[...path]/route.ts b/app/api/performance-survey/[...path]/route.ts index 7a6d8ca..00c6f5b 100644 --- a/app/api/performance-survey/[...path]/route.ts +++ b/app/api/performance-survey/[...path]/route.ts @@ -7,7 +7,10 @@ import { type DingtalkPerformanceSessionScope, verifyDingtalkPerformanceSessionToken, } from '@/lib/dingtalk-performance-session' -import { DINGTALK_PERFORMANCE_SESSION_COOKIE } from '@/lib/dingtalk-performance-session-constants' +import { + DINGTALK_PERFORMANCE_SESSION_API_COOKIE, + DINGTALK_PERFORMANCE_SESSION_COOKIE, +} from '@/lib/dingtalk-performance-session-constants' import { PerformanceSurveyApiError, createPerformanceSurveyWorkItemFromRequest, @@ -85,31 +88,44 @@ function errorResponse(error: unknown) { return NextResponse.json({ success: false, error: message }, { status: 500 }) } -function readDingtalkPerformanceToken(request: Request) { - const headerToken = request.headers.get(DINGTALK_PERFORMANCE_SESSION_HEADER) - if (headerToken) { - return headerToken +function readDingtalkPerformanceTokens(request: Request) { + const tokens: string[] = [] + const add = (value: string | null | undefined) => { + const token = String(value || '').trim() + if (token && !tokens.includes(token)) tokens.push(token) } + const headerToken = request.headers.get(DINGTALK_PERFORMANCE_SESSION_HEADER) + add(headerToken) + const authorization = request.headers.get('authorization') || '' const match = authorization.match(/^Bearer\s+(.+)$/i) - if (match?.[1]?.trim()) return match[1].trim() + add(match?.[1]) const cookieHeader = request.headers.get('cookie') || '' - const cookie = cookieHeader + const cookies = cookieHeader .split(';') .map((part) => part.trim()) - .find((part) => part.startsWith(`${DINGTALK_PERFORMANCE_SESSION_COOKIE}=`)) - return cookie?.slice(DINGTALK_PERFORMANCE_SESSION_COOKIE.length + 1).trim() || null + const readCookie = (name: string) => { + const cookie = cookies.find((part) => part.startsWith(`${name}=`)) + if (!cookie) return null + const separator = cookie.indexOf('=') + return separator >= 0 ? cookie.slice(separator + 1).trim() || null : null + } + + // 新 Cookie 优先;旧版本可能还残留同名 API Cookie,不能让它遮蔽新凭证。 + add(readCookie(DINGTALK_PERFORMANCE_SESSION_API_COOKIE)) + add(readCookie(DINGTALK_PERFORMANCE_SESSION_COOKIE)) + return tokens } async function getSession(request: Request): Promise { // 钉钉工作通知会话必须优先于浏览器里可能残留的素材库/品牌中心会话。 // 否则用户从他人的评分通知进入时,API 会把旧的普通登录身份当成当前评分人。 - const performanceToken = readDingtalkPerformanceToken(request) - if (performanceToken) { + const performanceTokens = readDingtalkPerformanceTokens(request) + for (const performanceToken of performanceTokens) { const payload = verifyDingtalkPerformanceSessionToken(performanceToken) - if (!payload) return null + if (!payload) continue return { user: { @@ -127,6 +143,9 @@ async function getSession(request: Request): Promise { } as Session & { dingtalkPerformanceScope: DingtalkPerformanceSessionScope } } + // 请求带有绩效凭证但全部无效时,不降级到普通素材库会话,避免把通知身份错配。 + if (performanceTokens.length > 0) return null + return getServerSession(authOptions) } diff --git a/app/performance-survey/[[...path]]/page.tsx b/app/performance-survey/[[...path]]/page.tsx index 21d3c9c..58349bf 100644 --- a/app/performance-survey/[[...path]]/page.tsx +++ b/app/performance-survey/[[...path]]/page.tsx @@ -144,6 +144,7 @@ const API_BASE = '/api/performance-survey' const DINGTALK_PERFORMANCE_SESSION_STORAGE_KEY = 'performanceSurveyDingtalkToken' const DINGTALK_PERFORMANCE_SESSION_HEADER = 'x-performance-survey-dingtalk-session' const DINGTALK_PERFORMANCE_SESSION_EXPIRED_EVENT = 'performance-survey:dingtalk-session-expired' +const DINGTALK_PERFORMANCE_SESSION_HINT_COOKIE = 'dingtalk-performance-session-active' const PERFORMANCE_SURVEY_MAX_IMAGE_COUNT = 60 const PERFORMANCE_SURVEY_MAX_IMAGE_BYTES = 200 * 1024 * 1024 const DEFAULT_VISUAL_CASE_COUNT = 3 @@ -411,6 +412,19 @@ function getStoredDingtalkPerformanceToken() { } } +function hasDingtalkPerformanceSessionHint() { + if (typeof document === 'undefined') return false + return document.cookie + .split(';') + .map((part) => part.trim()) + .some((part) => part === `${DINGTALK_PERFORMANCE_SESSION_HINT_COOKIE}=1`) +} + +function clearDingtalkPerformanceSessionHint() { + if (typeof document === 'undefined') return + document.cookie = `${DINGTALK_PERFORMANCE_SESSION_HINT_COOKIE}=; Max-Age=0; Path=/performance-survey; SameSite=Lax` +} + function clearStoredDingtalkPerformanceToken() { if (typeof window === 'undefined') return try { @@ -510,9 +524,10 @@ async function apiRequest(path: string, options?: RequestInit): Promise { const payload = contentType.includes('application/json') ? await response.json() : await response.text() if (!response.ok || (payload && typeof payload === 'object' && payload.success === false)) { - if (response.status === 401 && dingtalkToken) { - clearStoredDingtalkPerformanceToken() + if (response.status === 401 && (dingtalkToken || hasDingtalkPerformanceSessionHint())) { + if (dingtalkToken) clearStoredDingtalkPerformanceToken() if (typeof window !== 'undefined') { + clearDingtalkPerformanceSessionHint() window.dispatchEvent(new Event(DINGTALK_PERFORMANCE_SESSION_EXPIRED_EVENT)) } throw new Error('钉钉考核调研访问凭证已过期,请从钉钉最新通知重新进入') @@ -591,7 +606,8 @@ export default function PerformanceSurveyPage() { useEffect(() => { const hasToken = Boolean(getStoredDingtalkPerformanceToken()) - setHasDingtalkPerformanceSession(hasToken) + const hasSessionHint = hasDingtalkPerformanceSessionHint() + setHasDingtalkPerformanceSession(hasToken || hasSessionHint) setDingtalkUserProfile(hasToken ? decodeStoredDingtalkPerformanceSession() : null) setDingtalkSessionChecked(true) }, []) diff --git a/lib/dingtalk-performance-session-constants.ts b/lib/dingtalk-performance-session-constants.ts index b7fea56..6dc7451 100644 --- a/lib/dingtalk-performance-session-constants.ts +++ b/lib/dingtalk-performance-session-constants.ts @@ -1 +1,6 @@ export const DINGTALK_PERFORMANCE_SESSION_COOKIE = 'dingtalk-performance-session' +// 页面导航与数据接口使用不同的限定路径 Cookie。NextResponse.cookies.set +// 对同名 Cookie 是覆盖写入,不能用同一个名称同时设置两个 Path。 +export const DINGTALK_PERFORMANCE_SESSION_API_COOKIE = 'dingtalk-performance-session-api' +// 仅用于客户端判断是否应等待钉钉会话;不包含凭证内容。 +export const DINGTALK_PERFORMANCE_SESSION_HINT_COOKIE = 'dingtalk-performance-session-active' diff --git a/lib/upload-auth.ts b/lib/upload-auth.ts index 71683a3..51ae51e 100644 --- a/lib/upload-auth.ts +++ b/lib/upload-auth.ts @@ -5,23 +5,37 @@ import { DINGTALK_PERFORMANCE_SESSION_HEADER, verifyDingtalkPerformanceSessionToken, } from '@/lib/dingtalk-performance-session' -import { DINGTALK_PERFORMANCE_SESSION_COOKIE } from '@/lib/dingtalk-performance-session-constants' +import { + DINGTALK_PERFORMANCE_SESSION_API_COOKIE, + DINGTALK_PERFORMANCE_SESSION_COOKIE, +} from '@/lib/dingtalk-performance-session-constants' export async function getAuthenticatedUploadUserId(request: NextRequest): Promise { - const session = await getServerSession(authOptions) - const sessionUserId = String((session?.user as any)?.id || '').trim() - if (sessionUserId) return sessionUserId - const headerToken = request.headers.get(DINGTALK_PERFORMANCE_SESSION_HEADER) const authorization = request.headers.get('authorization') || '' const bearerToken = authorization.match(/^Bearer\s+(.+)$/i)?.[1]?.trim() const cookieHeader = request.headers.get('cookie') || '' - const cookieToken = cookieHeader + const cookies = cookieHeader .split(';') .map((part) => part.trim()) - .find((part) => part.startsWith(`${DINGTALK_PERFORMANCE_SESSION_COOKIE}=`)) - ?.slice(DINGTALK_PERFORMANCE_SESSION_COOKIE.length + 1) - .trim() - const payload = verifyDingtalkPerformanceSessionToken(headerToken || bearerToken || cookieToken || null) - return payload?.userId ? String(payload.userId).trim() : null + const readCookie = (name: string) => { + const cookie = cookies.find((part) => part.startsWith(`${name}=`)) + if (!cookie) return null + const separator = cookie.indexOf('=') + return separator >= 0 ? cookie.slice(separator + 1).trim() || null : null + } + // 新 Cookie 优先;旧版本可能还残留同名 API Cookie,不能让它遮蔽新凭证。 + const cookieToken = readCookie(DINGTALK_PERFORMANCE_SESSION_API_COOKIE) + || readCookie(DINGTALK_PERFORMANCE_SESSION_COOKIE) + const performanceTokens = [headerToken, bearerToken, cookieToken] + .map((token) => String(token || '').trim()) + .filter((token, index, values) => Boolean(token) && values.indexOf(token) === index) + for (const performanceToken of performanceTokens) { + const performancePayload = verifyDingtalkPerformanceSessionToken(performanceToken) + if (performancePayload?.userId) return String(performancePayload.userId).trim() + } + + const session = await getServerSession(authOptions) + const sessionUserId = String((session?.user as any)?.id || '').trim() + return sessionUserId || null }