203 lines
5.9 KiB
TypeScript
203 lines
5.9 KiB
TypeScript
import { createHmac, randomUUID, timingSafeEqual } from 'crypto'
|
|
export { DINGTALK_PERFORMANCE_SESSION_COOKIE } from '@/lib/dingtalk-performance-session-constants'
|
|
|
|
export const DINGTALK_PERFORMANCE_SESSION_HEADER = 'x-performance-survey-dingtalk-session'
|
|
|
|
export type DingtalkPerformanceRole = 'USER' | 'ADMIN' | 'DEPT_ADMIN' | 'SYSTEM_ADMIN'
|
|
|
|
export type DingtalkPerformanceSessionScope =
|
|
| {
|
|
kind: 'assignment'
|
|
surveyId: string
|
|
targetId: string
|
|
}
|
|
| {
|
|
kind: 'results'
|
|
surveyId: string
|
|
targetId?: string | null
|
|
}
|
|
| {
|
|
kind: 'workspace'
|
|
}
|
|
|
|
export type DingtalkPerformanceSessionPayload = {
|
|
v: 1
|
|
purpose: 'performance-survey'
|
|
userId: string
|
|
email: string
|
|
name: string
|
|
avatar?: string | null
|
|
role: DingtalkPerformanceRole
|
|
department?: string | null
|
|
managedDepartment?: string | null
|
|
isSuperAdmin?: boolean | null
|
|
scope: DingtalkPerformanceSessionScope
|
|
iat: number
|
|
exp: number
|
|
nonce: string
|
|
}
|
|
|
|
type DingtalkPerformanceUser = {
|
|
id: string
|
|
email: string
|
|
username: string
|
|
avatar?: string | null
|
|
role: string
|
|
department?: string | null
|
|
managedDepartment?: string | null
|
|
isSuperAdmin?: boolean | null
|
|
}
|
|
|
|
const DEFAULT_TTL_SECONDS = 12 * 60 * 60
|
|
|
|
function getSecret() {
|
|
const secret = process.env.NEXTAUTH_SECRET
|
|
if (!secret) {
|
|
throw new Error('考核调研钉钉免登未配置 NEXTAUTH_SECRET')
|
|
}
|
|
return secret
|
|
}
|
|
|
|
function base64url(input: Buffer | string) {
|
|
return Buffer.from(input)
|
|
.toString('base64')
|
|
.replace(/=/g, '')
|
|
.replace(/\+/g, '-')
|
|
.replace(/\//g, '_')
|
|
}
|
|
|
|
function fromBase64url(input: string) {
|
|
const normalized = input.replace(/-/g, '+').replace(/_/g, '/')
|
|
const padding = normalized.length % 4 === 0 ? '' : '='.repeat(4 - (normalized.length % 4))
|
|
return Buffer.from(normalized + padding, 'base64').toString('utf8')
|
|
}
|
|
|
|
function sign(encodedPayload: string) {
|
|
return base64url(createHmac('sha256', getSecret()).update(encodedPayload).digest())
|
|
}
|
|
|
|
function safeEqual(left: string, right: string) {
|
|
const leftBuffer = Buffer.from(left)
|
|
const rightBuffer = Buffer.from(right)
|
|
if (leftBuffer.length !== rightBuffer.length) return false
|
|
return timingSafeEqual(leftBuffer, rightBuffer)
|
|
}
|
|
|
|
function getTtlSeconds() {
|
|
const configured = Number(process.env.DINGTALK_PERFORMANCE_SESSION_TTL_SECONDS || '')
|
|
if (Number.isFinite(configured) && configured >= 300 && configured <= 7 * 24 * 60 * 60) {
|
|
return Math.floor(configured)
|
|
}
|
|
return DEFAULT_TTL_SECONDS
|
|
}
|
|
|
|
function normalizeRole(role: string): DingtalkPerformanceRole {
|
|
return role === 'ADMIN' || role === 'DEPT_ADMIN' || role === 'SYSTEM_ADMIN'
|
|
? role
|
|
: 'USER'
|
|
}
|
|
|
|
export function resolveDingtalkPerformanceSessionScope(
|
|
redirectPath: string,
|
|
): DingtalkPerformanceSessionScope | null {
|
|
try {
|
|
const url = new URL(redirectPath, 'https://performance-survey.local')
|
|
const segments = url.pathname.split('/').filter(Boolean)
|
|
const surveyId = segments[2] || ''
|
|
const targetId = url.searchParams.get('targetId')?.trim() || ''
|
|
|
|
if (
|
|
segments.length === 3
|
|
&& segments[0] === 'performance-survey'
|
|
&& segments[1] === 'survey'
|
|
&& surveyId
|
|
&& targetId
|
|
) {
|
|
return { kind: 'assignment', surveyId, targetId }
|
|
}
|
|
|
|
if (
|
|
segments.length === 3
|
|
&& segments[0] === 'performance-survey'
|
|
&& segments[1] === 'results'
|
|
&& surveyId
|
|
) {
|
|
return { kind: 'results', surveyId, targetId: targetId || null }
|
|
}
|
|
} catch {
|
|
// redirectPath 已由入口层约束;异常值不能获得绩效工作区权限。
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
export function createDingtalkPerformanceSessionToken(
|
|
user: DingtalkPerformanceUser,
|
|
scope: DingtalkPerformanceSessionScope = { kind: 'workspace' },
|
|
) {
|
|
const now = Math.floor(Date.now() / 1000)
|
|
const payload: DingtalkPerformanceSessionPayload = {
|
|
v: 1,
|
|
purpose: 'performance-survey',
|
|
userId: user.id,
|
|
email: user.email,
|
|
name: user.username,
|
|
avatar: user.avatar || null,
|
|
role: normalizeRole(user.role),
|
|
department: user.department || '',
|
|
managedDepartment: user.managedDepartment || null,
|
|
isSuperAdmin: user.isSuperAdmin ?? false,
|
|
scope,
|
|
iat: now,
|
|
exp: now + getTtlSeconds(),
|
|
nonce: randomUUID(),
|
|
}
|
|
const encodedPayload = base64url(JSON.stringify(payload))
|
|
return `${encodedPayload}.${sign(encodedPayload)}`
|
|
}
|
|
|
|
export function verifyDingtalkPerformanceSessionToken(token: string | null | undefined) {
|
|
const normalized = String(token || '').trim()
|
|
if (!normalized) return null
|
|
|
|
const [encodedPayload, signature, extra] = normalized.split('.')
|
|
if (!encodedPayload || !signature || extra) return null
|
|
|
|
if (!safeEqual(sign(encodedPayload), signature)) return null
|
|
|
|
try {
|
|
const payload = JSON.parse(fromBase64url(encodedPayload)) as Partial<DingtalkPerformanceSessionPayload>
|
|
const now = Math.floor(Date.now() / 1000)
|
|
if (
|
|
payload.v !== 1 ||
|
|
payload.purpose !== 'performance-survey' ||
|
|
!payload.userId ||
|
|
!payload.email ||
|
|
!payload.name ||
|
|
!payload.role ||
|
|
normalizeRole(payload.role) !== payload.role ||
|
|
!isValidScope(payload.scope) ||
|
|
typeof payload.exp !== 'number' ||
|
|
payload.exp <= now
|
|
) {
|
|
return null
|
|
}
|
|
|
|
return payload as DingtalkPerformanceSessionPayload
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function isValidScope(scope: unknown): scope is DingtalkPerformanceSessionScope {
|
|
if (!scope || typeof scope !== 'object' || Array.isArray(scope)) return false
|
|
const value = scope as Record<string, unknown>
|
|
if (value.kind === 'workspace') return true
|
|
if (value.kind !== 'assignment' && value.kind !== 'results') return false
|
|
if (typeof value.surveyId !== 'string' || !value.surveyId.trim()) return false
|
|
if (value.kind === 'assignment') {
|
|
return typeof value.targetId === 'string' && Boolean(value.targetId.trim())
|
|
}
|
|
return value.targetId == null || typeof value.targetId === 'string'
|
|
}
|