fix: preserve dingtalk survey session across paths
This commit is contained in:
parent
1481f56eb2
commit
8df5b31f8c
@ -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',
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}, [])
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user