fix: preserve dingtalk survey session across paths

This commit is contained in:
江林铖 2026-09-04 16:07:20 +08:00
parent 1481f56eb2
commit 8df5b31f8c
6 changed files with 108 additions and 28 deletions

View File

@ -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',

View File

@ -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
}

View File

@ -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<Session | null> {
// 钉钉工作通知会话必须优先于浏览器里可能残留的素材库/品牌中心会话。
// 否则用户从他人的评分通知进入时,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<Session | null> {
} as Session & { dingtalkPerformanceScope: DingtalkPerformanceSessionScope }
}
// 请求带有绩效凭证但全部无效时,不降级到普通素材库会话,避免把通知身份错配。
if (performanceTokens.length > 0) return null
return getServerSession(authOptions)
}

View File

@ -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<T>(path: string, options?: RequestInit): Promise<T> {
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)
}, [])

View File

@ -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'

View File

@ -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<string | null> {
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
}