135 lines
3.8 KiB
TypeScript
135 lines
3.8 KiB
TypeScript
import { createHmac, randomUUID, timingSafeEqual } from 'crypto'
|
|
|
|
export const DINGTALK_PERFORMANCE_SESSION_HEADER = 'x-performance-survey-dingtalk-session'
|
|
|
|
export type DingtalkPerformanceRole = 'USER' | 'ADMIN' | 'DEPT_ADMIN' | 'SYSTEM_ADMIN'
|
|
|
|
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
|
|
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 createDingtalkPerformanceSessionToken(user: DingtalkPerformanceUser) {
|
|
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,
|
|
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 ||
|
|
typeof payload.exp !== 'number' ||
|
|
payload.exp <= now
|
|
) {
|
|
return null
|
|
}
|
|
|
|
return payload as DingtalkPerformanceSessionPayload
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|