195 lines
5.2 KiB
TypeScript
195 lines
5.2 KiB
TypeScript
import { createHash, randomUUID, timingSafeEqual } from 'crypto'
|
|
|
|
type DingtalkSessionTicket = {
|
|
id: string
|
|
redirectPath: string
|
|
cookies?: string[]
|
|
userId?: string | null
|
|
targetOrigin?: string | null
|
|
handoffId?: string | null
|
|
handoffBindingHash?: string | null
|
|
nonce?: string | null
|
|
expiresAt: number
|
|
}
|
|
|
|
const TICKET_TTL_SECONDS = 60
|
|
const TICKET_TTL_MS = TICKET_TTL_SECONDS * 1_000
|
|
const REDIS_KEY_PREFIX = 'dingtalk:session-ticket:'
|
|
const tickets = new Map<string, DingtalkSessionTicket>()
|
|
|
|
function cleanupExpiredTickets() {
|
|
const now = Date.now()
|
|
tickets.forEach((ticket, id) => {
|
|
if (ticket.expiresAt <= now) {
|
|
tickets.delete(id)
|
|
}
|
|
})
|
|
}
|
|
|
|
function shouldUseRedisTicketStore() {
|
|
return process.env.USE_REDIS === 'true' && Boolean(process.env.REDIS_URL)
|
|
}
|
|
|
|
function getRedisKey(id: string) {
|
|
return `${REDIS_KEY_PREFIX}${id}`
|
|
}
|
|
|
|
function normalizeTicket(id: string, raw: string): DingtalkSessionTicket | null {
|
|
try {
|
|
const ticket = JSON.parse(raw) as DingtalkSessionTicket
|
|
if (
|
|
ticket.id !== id ||
|
|
(!Array.isArray(ticket.cookies) && !ticket.userId) ||
|
|
(Array.isArray(ticket.cookies) && !ticket.cookies.every((cookie) => typeof cookie === 'string' && cookie.length > 0)) ||
|
|
typeof ticket.redirectPath !== 'string' ||
|
|
!Number.isFinite(ticket.expiresAt) ||
|
|
ticket.expiresAt <= Date.now()
|
|
) {
|
|
return null
|
|
}
|
|
return ticket
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function matchesExpectedTargetOrigin(ticket: DingtalkSessionTicket, expectedTargetOrigin?: string | null) {
|
|
if (!ticket.targetOrigin) {
|
|
return true
|
|
}
|
|
|
|
return ticket.targetOrigin === String(expectedTargetOrigin || '').trim()
|
|
}
|
|
|
|
function createBindingHash(value: string) {
|
|
return createHash('sha256').update(value).digest('hex')
|
|
}
|
|
|
|
function matchesExpectedHandoffBinding(ticket: DingtalkSessionTicket, expectedHandoffBinding?: string | null) {
|
|
if (!ticket.handoffBindingHash) {
|
|
return true
|
|
}
|
|
|
|
const expected = String(expectedHandoffBinding || '')
|
|
if (!expected) return false
|
|
|
|
const expectedHash = createBindingHash(expected)
|
|
const left = Buffer.from(ticket.handoffBindingHash, 'hex')
|
|
const right = Buffer.from(expectedHash, 'hex')
|
|
return left.length === right.length && timingSafeEqual(left, right)
|
|
}
|
|
|
|
export async function createDingtalkSessionTicket(input: {
|
|
redirectPath: string
|
|
cookies?: string[]
|
|
userId?: string | null
|
|
targetOrigin?: string | null
|
|
handoffId?: string | null
|
|
handoffBindingHash?: string | null
|
|
nonce?: string | null
|
|
}) {
|
|
if (!input.userId && (!Array.isArray(input.cookies) || input.cookies.length === 0)) {
|
|
throw new Error('创建钉钉登录会话交接票据时缺少会话内容')
|
|
}
|
|
|
|
const id = randomUUID()
|
|
const ticket: DingtalkSessionTicket = {
|
|
id,
|
|
redirectPath: input.redirectPath,
|
|
cookies: input.cookies || [],
|
|
userId: input.userId || null,
|
|
targetOrigin: input.targetOrigin || null,
|
|
handoffId: input.handoffId || null,
|
|
handoffBindingHash: input.handoffBindingHash || null,
|
|
nonce: input.nonce || null,
|
|
expiresAt: Date.now() + TICKET_TTL_MS,
|
|
}
|
|
|
|
if (shouldUseRedisTicketStore()) {
|
|
const { redis } = await import('@/lib/upload-sessions-redis')
|
|
const saved = await redis.set(
|
|
getRedisKey(id),
|
|
JSON.stringify(ticket),
|
|
'EX',
|
|
TICKET_TTL_SECONDS,
|
|
'NX',
|
|
)
|
|
if (saved !== 'OK') {
|
|
throw new Error('创建钉钉登录会话交接票据失败')
|
|
}
|
|
return id
|
|
}
|
|
|
|
cleanupExpiredTickets()
|
|
tickets.set(id, ticket)
|
|
|
|
return id
|
|
}
|
|
|
|
export async function consumeDingtalkSessionTicket(
|
|
id: string | null | undefined,
|
|
options?: {
|
|
expectedTargetOrigin?: string | null
|
|
expectedHandoffBinding?: string | null
|
|
},
|
|
) {
|
|
const normalizedId = String(id || '').trim()
|
|
if (!normalizedId) {
|
|
return null
|
|
}
|
|
|
|
if (shouldUseRedisTicketStore()) {
|
|
const { redis } = await import('@/lib/upload-sessions-redis')
|
|
const key = getRedisKey(normalizedId)
|
|
const raw = await redis.get(key)
|
|
if (!raw) return null
|
|
|
|
const ticket = normalizeTicket(normalizedId, raw)
|
|
if (
|
|
!ticket ||
|
|
!matchesExpectedTargetOrigin(ticket, options?.expectedTargetOrigin) ||
|
|
!matchesExpectedHandoffBinding(ticket, options?.expectedHandoffBinding)
|
|
) {
|
|
return null
|
|
}
|
|
|
|
const consumed = await redis.eval(
|
|
'if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) end return 0',
|
|
1,
|
|
key,
|
|
raw,
|
|
)
|
|
if (Number(consumed) !== 1) return null
|
|
|
|
return {
|
|
redirectPath: ticket.redirectPath,
|
|
cookies: ticket.cookies || [],
|
|
userId: ticket.userId || null,
|
|
targetOrigin: ticket.targetOrigin || null,
|
|
handoffId: ticket.handoffId || null,
|
|
}
|
|
}
|
|
|
|
cleanupExpiredTickets()
|
|
const ticket = tickets.get(normalizedId)
|
|
|
|
if (
|
|
!ticket ||
|
|
ticket.expiresAt <= Date.now() ||
|
|
!matchesExpectedTargetOrigin(ticket, options?.expectedTargetOrigin) ||
|
|
!matchesExpectedHandoffBinding(ticket, options?.expectedHandoffBinding)
|
|
) {
|
|
return null
|
|
}
|
|
|
|
tickets.delete(normalizedId)
|
|
|
|
return {
|
|
redirectPath: ticket.redirectPath,
|
|
cookies: ticket.cookies || [],
|
|
userId: ticket.userId || null,
|
|
targetOrigin: ticket.targetOrigin || null,
|
|
handoffId: ticket.handoffId || null,
|
|
}
|
|
}
|