fix: 钉钉登录后安全返回内网

This commit is contained in:
chenpei 2026-07-17 18:55:37 +08:00
parent 94fb4e1898
commit 3f56e0280f
16 changed files with 894 additions and 83 deletions

View File

@ -4,6 +4,76 @@ import {
DINGTALK_DIRECT_LOGIN_SOURCE,
parseDingtalkOAuthState,
} from '@/lib/dingtalk-oauth'
import { getDingtalkLoginHandoff } from '@/lib/dingtalk-login-handoff'
import { createDingtalkSessionTicket } from '@/lib/dingtalk-session-ticket'
function getSetCookies(source: Response): string[] {
const getSetCookie = (source.headers as any).getSetCookie
const cookies = typeof getSetCookie === 'function'
? getSetCookie.call(source.headers)
: [source.headers.get('set-cookie')].filter(Boolean)
return cookies.filter((cookie: unknown): cookie is string => typeof cookie === 'string' && cookie.length > 0)
}
function appendSetCookies(target: NextResponse, cookies: string[]) {
for (const cookie of cookies) {
target.headers.append('set-cookie', cookie)
}
}
function isSafeHandoffNonce(value: unknown) {
return /^[A-Za-z0-9_-]{16,128}$/.test(String(value || ''))
}
async function attachInternalSessionHandoff(source: NextResponse, state: unknown) {
const data = await source.clone().json().catch(() => null)
if (!source.ok || !data?.success || data?.needCompleteProfile || !data?.user?.id) {
return source
}
const { handoffId, nonce } = parseDingtalkOAuthState(state)
if (!handoffId || !isSafeHandoffNonce(nonce)) {
return source
}
try {
const handoff = await getDingtalkLoginHandoff(handoffId)
if (!handoff) {
return source
}
const ticket = await createDingtalkSessionTicket({
userId: String(data.user.id),
redirectPath: handoff.redirectPath,
targetOrigin: handoff.returnOrigin,
handoffId: handoff.id,
handoffBindingHash: handoff.bindingHash,
nonce,
})
const response = NextResponse.json(
{
...data,
sessionHandoff: {
ticket,
targetOrigin: handoff.returnOrigin,
nonce,
},
},
{
status: source.status,
headers: { 'Cache-Control': 'no-store' },
},
)
appendSetCookies(response, getSetCookies(source))
return response
} catch (error) {
// 公网登录已经成功,Redis 临时不可用时保留现有公网落地,而不是中断用户登录。
console.error('[Dingtalk] 创建内网登录交接票据失败:', error)
return source
}
}
/**
* R25修复:移除硬编码的钉钉密钥,强制使用环境变量
@ -25,9 +95,10 @@ export async function POST(request: NextRequest) {
}
const { source } = parseDingtalkOAuthState(state)
return loginDingtalkUserByOAuthCode(request, String(code), {
const response = await loginDingtalkUserByOAuthCode(request, String(code), {
appSource: source === DINGTALK_DIRECT_LOGIN_SOURCE ? 'workNotice' : 'login',
})
return attachInternalSessionHandoff(response, state)
} catch (error: any) {
console.error('钉钉登录失败:', error)
return NextResponse.json(

View File

@ -1,6 +1,14 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getPublicAppOrigin } from '@/lib/public-app-url'
import { consumeDingtalkSessionTicket } from '@/lib/dingtalk-session-ticket'
import {
deleteDingtalkLoginHandoff,
DINGTALK_LOGIN_HANDOFF_COOKIE,
} from '@/lib/dingtalk-login-handoff'
import { getTrustedDingtalkReturnOrigin } from '@/lib/dingtalk-return-origin'
import { setDingtalkAuthenticatedSessionCookies } from '@/lib/dingtalk-login'
import { resolveSecureAuthCookieFromRequest } from '@/lib/auth-cookie'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
@ -66,9 +74,9 @@ function stringifyForInlineScript(value: string) {
return JSON.stringify(value).replace(/</g, '\\u003c')
}
function buildPublicUrl(path: string, request?: NextRequest) {
function buildRedirectUrl(path: string, request?: NextRequest, targetOrigin?: string | null) {
try {
return new URL(path, getPublicAppOrigin()).toString()
return new URL(path, targetOrigin || getPublicAppOrigin()).toString()
} catch {
return new URL(path, request?.url || 'https://sc.jiansun.vip').toString()
}
@ -128,6 +136,7 @@ function createErrorPage(message: string, status = 410) {
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
'Referrer-Policy': 'no-referrer',
},
},
)
@ -137,12 +146,14 @@ function createSessionRedirectResponse({
request,
redirectPath,
cookies,
targetOrigin,
}: {
request: NextRequest
redirectPath: string
cookies: string[]
targetOrigin?: string | null
}) {
const redirectUrl = buildPublicUrl(redirectPath, request)
const redirectUrl = buildRedirectUrl(redirectPath, request, targetOrigin)
const response = new NextResponse(
`<!doctype html>
<html lang="zh-CN">
@ -211,6 +222,7 @@ ${SURVEY_OPENING_PAGE_MARKUP}
'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
Pragma: 'no-cache',
Expires: '0',
'Referrer-Policy': 'no-referrer',
},
},
)
@ -225,15 +237,88 @@ ${SURVEY_OPENING_PAGE_MARKUP}
return response
}
export async function GET(request: NextRequest) {
const ticket = consumeDingtalkSessionTicket(request.nextUrl.searchParams.get('ticket'))
if (!ticket) {
return createErrorPage('暂时无法打开问卷,请从最新通知重新进入。')
async function resolveActiveTicketUser(userId: string) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
username: true,
realName: true,
role: true,
department: true,
managedDepartment: true,
avatar: true,
isSuperAdmin: true,
status: true,
isActive: true,
},
})
if (!user) return null
const status = user.status || (user.isActive ? 'active' : 'pending')
if (status !== 'active') {
return null
}
return createSessionRedirectResponse({
return user
}
export async function GET(request: NextRequest) {
const requestOrigin = request.nextUrl.origin
const handoffBinding = request.cookies.get(DINGTALK_LOGIN_HANDOFF_COOKIE)?.value || null
let ticket
try {
ticket = await consumeDingtalkSessionTicket(request.nextUrl.searchParams.get('ticket'), {
expectedTargetOrigin: requestOrigin,
expectedHandoffBinding: handoffBinding,
})
} catch (error) {
console.error('[Dingtalk] 消费登录会话交接票据失败:', error)
return createErrorPage('暂时无法完成登录交接,请重新登录。', 503)
}
if (!ticket) {
return createErrorPage('登录交接已失效或不是当前浏览器发起,请重新登录。')
}
if (ticket.targetOrigin && getTrustedDingtalkReturnOrigin(ticket.targetOrigin) !== requestOrigin) {
return createErrorPage('登录交接来源校验失败,请重新登录。')
}
let ticketUser = null
if (ticket.userId) {
ticketUser = await resolveActiveTicketUser(ticket.userId)
if (!ticketUser) {
return createErrorPage('账号状态已变化,请重新登录。', 403)
}
}
const response = createSessionRedirectResponse({
request,
redirectPath: ticket.redirectPath,
cookies: ticket.cookies,
targetOrigin: ticket.targetOrigin,
})
if (ticketUser) {
await setDingtalkAuthenticatedSessionCookies(response, request, ticketUser)
response.cookies.set(DINGTALK_LOGIN_HANDOFF_COOKIE, '', {
httpOnly: true,
sameSite: 'lax',
secure: resolveSecureAuthCookieFromRequest(request),
path: '/api/dingtalk/session-ticket',
maxAge: 0,
})
if (ticket.handoffId) {
await deleteDingtalkLoginHandoff(ticket.handoffId).catch((error) => {
console.warn('[Dingtalk] 清理已完成的登录交接请求失败:', error)
})
}
}
return response
}

View File

@ -0,0 +1,61 @@
/** @jest-environment node */
import { NextRequest } from 'next/server'
import { POST } from './route'
import {
deleteDingtalkLoginHandoff,
getDingtalkLoginHandoff,
} from '@/lib/dingtalk-login-handoff'
describe('POST /api/dingtalk/session-transfer/start', () => {
const originalUseRedis = process.env.USE_REDIS
const originalRedisUrl = process.env.REDIS_URL
const originalOrigins = process.env.DINGTALK_INTERNAL_RETURN_ORIGINS
beforeEach(() => {
process.env.USE_REDIS = 'false'
delete process.env.REDIS_URL
process.env.DINGTALK_INTERNAL_RETURN_ORIGINS = 'http://192.168.58.20:4320'
})
afterAll(() => {
if (originalUseRedis === undefined) delete process.env.USE_REDIS
else process.env.USE_REDIS = originalUseRedis
if (originalRedisUrl === undefined) delete process.env.REDIS_URL
else process.env.REDIS_URL = originalRedisUrl
if (originalOrigins === undefined) delete process.env.DINGTALK_INTERNAL_RETURN_ORIGINS
else process.env.DINGTALK_INTERNAL_RETURN_ORIGINS = originalOrigins
})
test('仅在受信内网入口创建绑定 Cookie 与交接 ID', async () => {
const response = await POST(new NextRequest('http://192.168.58.20:4320/api/dingtalk/session-transfer/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ redirectPath: '/admin?tab=users' }),
}))
const data = await response.json()
expect(data).toMatchObject({ success: true, enabled: true })
expect(response.headers.get('set-cookie')).toContain('dingtalk-login-handoff=')
const stored = await getDingtalkLoginHandoff(data.handoffId)
expect(stored).toMatchObject({
returnOrigin: 'http://192.168.58.20:4320',
redirectPath: '/admin?tab=users',
})
await deleteDingtalkLoginHandoff(data.handoffId)
})
test('公网入口不创建内网交接记录', async () => {
const response = await POST(new NextRequest('https://sc.jiansun.vip/api/dingtalk/session-transfer/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ redirectPath: '/admin' }),
}))
await expect(response.json()).resolves.toEqual({ success: true, enabled: false })
expect(response.headers.get('set-cookie')).toBeNull()
})
})

View File

@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server'
import {
createDingtalkLoginHandoff,
DINGTALK_LOGIN_HANDOFF_COOKIE,
} from '@/lib/dingtalk-login-handoff'
import { getTrustedDingtalkReturnOrigin } from '@/lib/dingtalk-return-origin'
import { normalizeInternalRedirectPath } from '@/lib/safe-redirect'
import { resolveSecureAuthCookieFromRequest } from '@/lib/auth-cookie'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export async function POST(request: NextRequest) {
const requestOrigin = request.nextUrl.origin
const trustedOrigin = getTrustedDingtalkReturnOrigin(requestOrigin)
// 公网登录不需要、也不能创建内网回跳票据;保持既有的公网登录流程。
if (!trustedOrigin) {
return NextResponse.json(
{ success: true, enabled: false },
{ headers: { 'Cache-Control': 'no-store' } },
)
}
const body = await request.json().catch(() => ({}))
const handoff = await createDingtalkLoginHandoff({
returnOrigin: trustedOrigin,
redirectPath: normalizeInternalRedirectPath(body?.redirectPath),
})
const response = NextResponse.json(
{
success: true,
enabled: true,
handoffId: handoff.id,
},
{ headers: { 'Cache-Control': 'no-store' } },
)
response.cookies.set(DINGTALK_LOGIN_HANDOFF_COOKIE, handoff.binding, {
httpOnly: true,
sameSite: 'lax',
secure: resolveSecureAuthCookieFromRequest(request),
path: '/api/dingtalk/session-ticket',
maxAge: 5 * 60,
})
return response
}

View File

@ -24,6 +24,40 @@ function parseDingtalkStateSource(value: string) {
}
}
function parseDingtalkLoginState(value: string) {
let raw = value
try {
raw = decodeURIComponent(raw)
} catch {
// URLSearchParams 通常已解码;兼容手工拼接的 state。
}
try {
const data = JSON.parse(raw)
return {
redirectPath: normalizeInternalRedirectPath(data?.redirect),
nonce: typeof data?.nonce === 'string' ? data.nonce : '',
}
} catch {
return { redirectPath: '/', nonce: '' }
}
}
function buildSessionTicketUrl(handoff: any) {
if (!handoff || typeof handoff.ticket !== 'string' || typeof handoff.targetOrigin !== 'string') {
return null
}
try {
const target = new URL('/api/dingtalk/session-ticket', handoff.targetOrigin)
if (target.origin !== handoff.targetOrigin) return null
target.searchParams.set('ticket', handoff.ticket)
return target.toString()
} catch {
return null
}
}
function DingtalkCallbackContent() {
const router = useRouter()
const searchParams = useSearchParams()
@ -46,16 +80,7 @@ function DingtalkCallbackContent() {
throw new Error('授权失败:缺少授权码')
}
// 解析state参数,获取原始访问地址
let originUrl = ''
let redirectPath = '/'
try {
const stateData = JSON.parse(decodeURIComponent(state))
originUrl = stateData.origin || ''
redirectPath = normalizeInternalRedirectPath(stateData.redirect)
} catch (e) {
// state解析失败,忽略
}
const { redirectPath, nonce } = parseDingtalkLoginState(state)
setMessage('正在验证授权信息...')
@ -86,8 +111,9 @@ function DingtalkCallbackContent() {
{
type: 'dingtalk_need_complete_profile',
userInfo: data.user,
nonce,
},
'*'
window.location.origin,
)
window.close()
} else if (window.parent !== window) {
@ -96,8 +122,9 @@ function DingtalkCallbackContent() {
{
type: 'dingtalk_need_complete_profile',
userInfo: data.user,
nonce,
},
'*'
window.location.origin,
)
} else {
router.push(`/complete-profile?userInfo=${userInfoParam}`)
@ -108,6 +135,8 @@ function DingtalkCallbackContent() {
setMessage('登录成功,正在跳转...')
setStatus('success')
const sessionHandoff = data.sessionHandoff
const sessionTicketUrl = buildSessionTicketUrl(sessionHandoff)
// 通知父窗口登录成功(如果在 iframe 或新窗口中)
if (window.opener) {
@ -116,10 +145,11 @@ function DingtalkCallbackContent() {
{
type: 'dingtalk_login_success',
userInfo: data.user,
originUrl: originUrl, // 携带原始地址
redirectUrl: redirectPath,
nonce,
sessionHandoff,
},
'*'
sessionHandoff?.targetOrigin || window.location.origin,
)
} else if (window.parent !== window) {
// iframe模式:通知父窗口(兼容旧代码)
@ -127,10 +157,11 @@ function DingtalkCallbackContent() {
{
type: 'dingtalk_login_success',
userInfo: data.user,
originUrl: originUrl, // 携带原始地址
redirectUrl: redirectPath,
nonce,
sessionHandoff,
},
'*'
sessionHandoff?.targetOrigin || window.location.origin,
)
} else {
await new Promise((resolve) => setTimeout(resolve, 200))
@ -144,7 +175,7 @@ function DingtalkCallbackContent() {
}
setTimeout(() => {
window.location.href = redirectPath
window.location.href = sessionTicketUrl || redirectPath
}, 300)
}
} catch (error: any) {
@ -164,11 +195,17 @@ function DingtalkCallbackContent() {
setTimeout(() => {
if (window.opener) {
// 新窗口模式:通知打开窗口的父页面
window.opener.postMessage({ type: 'dingtalk_login_error', message: error.message }, '*')
window.opener.postMessage(
{ type: 'dingtalk_login_error', message: error.message, nonce: parseDingtalkLoginState(state).nonce },
window.location.origin,
)
window.close()
} else if (window.parent !== window) {
// iframe模式:通知父窗口(兼容旧代码)
window.parent.postMessage({ type: 'dingtalk_login_error', message: error.message }, '*')
window.parent.postMessage(
{ type: 'dingtalk_login_error', message: error.message, nonce: parseDingtalkLoginState(state).nonce },
window.location.origin,
)
} else {
router.push('/login')
}

View File

@ -6,7 +6,7 @@ import Image from 'next/image'
import Link from 'next/link'
import { ensureHttpsUrl } from '@/lib/url-rewrite'
import { parseApiError } from '@/lib/error-handler'
import { getAllowedPublicAppOrigins, getPublicAppOrigin } from '@/lib/public-app-url'
import { getPublicAppOrigin } from '@/lib/public-app-url'
import { normalizeInternalRedirectPath } from '@/lib/safe-redirect'
const DEFAULT_LOGIN_LOGO_URL = '/api/favicon'
@ -25,7 +25,6 @@ export default function LoginPage() {
// 钉钉配置(从环境变量或配置文件获取)
const DINGTALK_CLIENT_ID = process.env.NEXT_PUBLIC_DINGTALK_CLIENT_ID || 'ding1bs1gxwrcfu16x0f'
const publicAppOrigin = getPublicAppOrigin()
const allowedMessageOrigins = getAllowedPublicAppOrigins()
const dingtalkCallbackOrigin =
process.env.NEXT_PUBLIC_DINGTALK_CALLBACK_ORIGIN?.trim() || publicAppOrigin
@ -154,13 +153,35 @@ export default function LoginPage() {
}
}
const handleDingtalkLogin = (options?: { redirectPath?: string; useCurrentWindow?: boolean }) => {
const handleDingtalkLogin = async (options?: { redirectPath?: string; useCurrentWindow?: boolean }) => {
const redirectUrl = `${dingtalkCallbackOrigin}/dingtalk-callback`
const redirectPath = normalizeInternalRedirectPath(options?.redirectPath ?? getLoginRedirectPath())
// 在state中携带原始访问地址信息(用于登录后跳转)
const currentOrigin = window.location.origin
const stateData = JSON.stringify({ origin: currentOrigin, redirect: redirectPath })
const nonce = typeof window.crypto?.randomUUID === 'function'
? window.crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`
let handoffId = ''
// 内网入口先在本机写入短时 HttpOnly 绑定 Cookie。OAuth 回到公网后只能携带无语义 ID,
// 最终仍必须由同一浏览器带着该 Cookie 消费一次性票据,避免登录注入和开放重定向。
try {
const handoffResponse = await fetch('/api/dingtalk/session-transfer/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ redirectPath }),
})
const handoffData = handoffResponse.ok
? await handoffResponse.json().catch(() => null)
: null
if (handoffData?.enabled && typeof handoffData.handoffId === 'string') {
handoffId = handoffData.handoffId
}
} catch (error) {
// 交接不可用时仍允许原有公网钉钉登录,不能把用户卡在登录页。
console.warn('创建钉钉内网登录交接请求失败,将使用公网登录:', error)
}
const stateData = JSON.stringify({ redirect: redirectPath, nonce, handoffId })
const params = new URLSearchParams({
redirect_uri: redirectUrl,
@ -200,17 +221,35 @@ export default function LoginPage() {
}
// 监听新窗口的消息(从回调页面发送)
const callbackMessageOrigin = new URL(dingtalkCallbackOrigin).origin
const handleMessage = async (event: MessageEvent) => {
if (!allowedMessageOrigins.includes(event.origin) && !event.origin.includes('192.168.')) {
if (event.origin !== callbackMessageOrigin) {
return
}
if (!event.data || event.data.nonce !== nonce) {
return
}
if (event.data && event.data.type === 'dingtalk_login_success') {
if (event.data.type === 'dingtalk_login_success') {
popup?.close()
window.removeEventListener('message', handleMessage)
const targetPath = normalizeInternalRedirectPath(event.data.redirectUrl ?? redirectPath)
const handoff = event.data.sessionHandoff
const canReturnToCurrentOrigin =
typeof handoff?.ticket === 'string' &&
handoff.nonce === nonce &&
handoff.targetOrigin === window.location.origin
if (canReturnToCurrentOrigin) {
const ticketUrl = new URL('/api/dingtalk/session-ticket', window.location.origin)
ticketUrl.searchParams.set('ticket', handoff.ticket)
window.location.href = ticketUrl.toString()
return
}
window.location.href = new URL(targetPath, dingtalkCallbackOrigin).toString()
} else if (event.data && event.data.type === 'dingtalk_need_complete_profile') {
} else if (event.data.type === 'dingtalk_need_complete_profile') {
popup?.close()
window.removeEventListener('message', handleMessage)
const userInfoParam = encodeURIComponent(JSON.stringify(event.data.userInfo))

View File

@ -49,6 +49,8 @@ services:
- APP_PUBLIC_URL=https://sc.jiansun.vip
- NEXT_PUBLIC_DINGTALK_CALLBACK_ORIGIN=https://sc.jiansun.vip
- NEXTAUTH_URL=https://sc.jiansun.vip
# 钉钉 OAuth 必须回公网;认证完成后仅允许把会话交接回这一条受信内网入口。
- DINGTALK_INTERNAL_RETURN_ORIGINS=http://192.168.58.20:4320
# 内网地址配置(用于流量分离:内网用户走内网网络,不占用外网带宽)
- SERVER_IP=192.168.58.20
- MINIO_API_PORT=9000

View File

@ -0,0 +1,44 @@
/** @jest-environment node */
import {
createDingtalkLoginHandoff,
deleteDingtalkLoginHandoff,
getDingtalkLoginHandoff,
} from '@/lib/dingtalk-login-handoff'
describe('DingTalk login handoff start record', () => {
const originalUseRedis = process.env.USE_REDIS
const originalRedisUrl = process.env.REDIS_URL
beforeEach(() => {
process.env.USE_REDIS = 'false'
delete process.env.REDIS_URL
})
afterAll(() => {
if (originalUseRedis === undefined) delete process.env.USE_REDIS
else process.env.USE_REDIS = originalUseRedis
if (originalRedisUrl === undefined) delete process.env.REDIS_URL
else process.env.REDIS_URL = originalRedisUrl
})
test('只存储绑定摘要,不暴露浏览器绑定值', async () => {
const handoff = await createDingtalkLoginHandoff({
returnOrigin: 'http://192.168.58.20:4320',
redirectPath: '/admin?tab=users',
})
const stored = await getDingtalkLoginHandoff(handoff.id)
expect(stored).toMatchObject({
id: handoff.id,
returnOrigin: 'http://192.168.58.20:4320',
redirectPath: '/admin?tab=users',
})
expect(stored?.bindingHash).toHaveLength(64)
expect(stored).not.toHaveProperty('binding')
await deleteDingtalkLoginHandoff(handoff.id)
await expect(getDingtalkLoginHandoff(handoff.id)).resolves.toBeNull()
})
})

View File

@ -0,0 +1,35 @@
/** @jest-environment node */
import {
getDingtalkReturnOrigins,
getTrustedDingtalkReturnOrigin,
} from '@/lib/dingtalk-return-origin'
describe('DingTalk internal return origin allowlist', () => {
const originalValue = process.env.DINGTALK_INTERNAL_RETURN_ORIGINS
afterEach(() => {
if (originalValue === undefined) {
delete process.env.DINGTALK_INTERNAL_RETURN_ORIGINS
} else {
process.env.DINGTALK_INTERNAL_RETURN_ORIGINS = originalValue
}
})
test('未配置时仅接受当前稳定内网入口', () => {
delete process.env.DINGTALK_INTERNAL_RETURN_ORIGINS
expect(getDingtalkReturnOrigins()).toEqual(['http://192.168.58.20:4320'])
expect(getTrustedDingtalkReturnOrigin('http://192.168.58.20:4320')).toBe('http://192.168.58.20:4320')
expect(getTrustedDingtalkReturnOrigin('http://192.168.58.20:4321')).toBeNull()
})
test('严格拒绝相似地址、路径和外部地址', () => {
process.env.DINGTALK_INTERNAL_RETURN_ORIGINS = 'http://192.168.58.20:4320,https://lan.example.test'
expect(getTrustedDingtalkReturnOrigin('https://lan.example.test')).toBe('https://lan.example.test')
expect(getTrustedDingtalkReturnOrigin('https://lan.example.test/redirect')).toBeNull()
expect(getTrustedDingtalkReturnOrigin('http://192.168.58.20.evil.test:4320')).toBeNull()
expect(getTrustedDingtalkReturnOrigin('https://sc.jiansun.vip')).toBeNull()
})
})

View File

@ -0,0 +1,80 @@
/** @jest-environment node */
import { createHash } from 'crypto'
import {
consumeDingtalkSessionTicket,
createDingtalkSessionTicket,
} from '@/lib/dingtalk-session-ticket'
describe('DingTalk one-time session ticket', () => {
const originalUseRedis = process.env.USE_REDIS
const originalRedisUrl = process.env.REDIS_URL
beforeEach(() => {
process.env.USE_REDIS = 'false'
delete process.env.REDIS_URL
})
afterAll(() => {
if (originalUseRedis === undefined) delete process.env.USE_REDIS
else process.env.USE_REDIS = originalUseRedis
if (originalRedisUrl === undefined) delete process.env.REDIS_URL
else process.env.REDIS_URL = originalRedisUrl
})
test('必须匹配原内网地址和浏览器绑定值,且只能消费一次', async () => {
const binding = 'binding-for-original-browser'
const ticketId = await createDingtalkSessionTicket({
userId: 'user-1',
redirectPath: '/admin?tab=users',
targetOrigin: 'http://192.168.58.20:4320',
handoffId: 'handoff-1',
handoffBindingHash: createHash('sha256').update(binding).digest('hex'),
})
await expect(
consumeDingtalkSessionTicket(ticketId, {
expectedTargetOrigin: 'http://192.168.58.20:4320',
expectedHandoffBinding: 'other-browser-binding',
}),
).resolves.toBeNull()
await expect(
consumeDingtalkSessionTicket(ticketId, {
expectedTargetOrigin: 'http://192.168.58.20:4321',
expectedHandoffBinding: binding,
}),
).resolves.toBeNull()
await expect(
consumeDingtalkSessionTicket(ticketId, {
expectedTargetOrigin: 'http://192.168.58.20:4320',
expectedHandoffBinding: binding,
}),
).resolves.toMatchObject({
userId: 'user-1',
redirectPath: '/admin?tab=users',
targetOrigin: 'http://192.168.58.20:4320',
})
await expect(
consumeDingtalkSessionTicket(ticketId, {
expectedTargetOrigin: 'http://192.168.58.20:4320',
expectedHandoffBinding: binding,
}),
).resolves.toBeNull()
})
test('现有仅写 Cookie 的会话票据仍能正常消费', async () => {
const ticketId = await createDingtalkSessionTicket({
redirectPath: '/performance-survey',
cookies: ['next-auth.session-token=test-token; Path=/; HttpOnly'],
})
await expect(consumeDingtalkSessionTicket(ticketId)).resolves.toMatchObject({
redirectPath: '/performance-survey',
cookies: ['next-auth.session-token=test-token; Path=/; HttpOnly'],
})
})
})

View File

@ -0,0 +1,113 @@
import { createHash, randomBytes, randomUUID } from 'crypto'
import { normalizeInternalRedirectPath } from '@/lib/safe-redirect'
type DingtalkLoginHandoff = {
id: string
returnOrigin: string
redirectPath: string
bindingHash: string
expiresAt: number
}
const HANDOFF_TTL_SECONDS = 5 * 60
const HANDOFF_TTL_MS = HANDOFF_TTL_SECONDS * 1_000
const HANDOFF_KEY_PREFIX = 'dingtalk:login-handoff:'
export const DINGTALK_LOGIN_HANDOFF_COOKIE = 'dingtalk-login-handoff'
const memoryHandoffs = new Map<string, DingtalkLoginHandoff>()
function shouldUseRedisHandoffStore() {
return process.env.USE_REDIS === 'true' && Boolean(process.env.REDIS_URL)
}
function hashBinding(value: string) {
return createHash('sha256').update(value).digest('hex')
}
function cleanupExpiredMemoryHandoffs() {
const now = Date.now()
memoryHandoffs.forEach((handoff, id) => {
if (handoff.expiresAt <= now) {
memoryHandoffs.delete(id)
}
})
}
function parseHandoff(id: string, raw: string): DingtalkLoginHandoff | null {
try {
const handoff = JSON.parse(raw) as DingtalkLoginHandoff
if (
handoff.id !== id ||
typeof handoff.returnOrigin !== 'string' ||
typeof handoff.redirectPath !== 'string' ||
typeof handoff.bindingHash !== 'string' ||
!Number.isFinite(handoff.expiresAt) ||
handoff.expiresAt <= Date.now()
) {
return null
}
return handoff
} catch {
return null
}
}
function getKey(id: string) {
return `${HANDOFF_KEY_PREFIX}${id}`
}
export async function createDingtalkLoginHandoff(input: {
returnOrigin: string
redirectPath: unknown
}) {
const id = randomUUID()
const binding = randomBytes(32).toString('base64url')
const handoff: DingtalkLoginHandoff = {
id,
returnOrigin: input.returnOrigin,
redirectPath: normalizeInternalRedirectPath(input.redirectPath),
bindingHash: hashBinding(binding),
expiresAt: Date.now() + HANDOFF_TTL_MS,
}
if (shouldUseRedisHandoffStore()) {
const { redis } = await import('@/lib/upload-sessions-redis')
const saved = await redis.set(getKey(id), JSON.stringify(handoff), 'EX', HANDOFF_TTL_SECONDS, 'NX')
if (saved !== 'OK') {
throw new Error('创建钉钉登录内网交接请求失败')
}
} else {
cleanupExpiredMemoryHandoffs()
memoryHandoffs.set(id, handoff)
}
return { id, binding, expiresAt: handoff.expiresAt }
}
export async function getDingtalkLoginHandoff(id: string | null | undefined) {
const normalizedId = String(id || '').trim()
if (!normalizedId) return null
if (shouldUseRedisHandoffStore()) {
const { redis } = await import('@/lib/upload-sessions-redis')
const raw = await redis.get(getKey(normalizedId))
return raw ? parseHandoff(normalizedId, raw) : null
}
cleanupExpiredMemoryHandoffs()
return memoryHandoffs.get(normalizedId) || null
}
export async function deleteDingtalkLoginHandoff(id: string | null | undefined) {
const normalizedId = String(id || '').trim()
if (!normalizedId) return
if (shouldUseRedisHandoffStore()) {
const { redis } = await import('@/lib/upload-sessions-redis')
await redis.del(getKey(normalizedId))
return
}
memoryHandoffs.delete(normalizedId)
}

View File

@ -30,7 +30,7 @@ type DingtalkUserInfo = {
type DingtalkLoginAppSource = 'login' | 'workNotice'
type DingtalkNextAuthUser = {
export type DingtalkNextAuthUser = {
id: string
email: string
username: string
@ -252,19 +252,10 @@ function warmPerformanceSurveyProfile(user: {
})
}
async function createAuthenticatedResponse(
export async function setDingtalkAuthenticatedSessionCookies(
response: NextResponse,
request: NextRequest,
user: {
id: string
email: string
username: string
realName?: string | null
role: Role
department: string
managedDepartment?: string | null
avatar?: string | null
isSuperAdmin?: boolean | null
},
user: DingtalkNextAuthUser,
options?: {
cookieSameSite?: 'lax' | 'none'
},
@ -297,22 +288,6 @@ async function createAuthenticatedResponse(
},
})
const response = NextResponse.json({
success: true,
needCompleteProfile: false,
message: '登录成功',
sessionCreated: true,
user: {
id: user.id,
username: user.username,
email: user.email,
realName: user.realName,
avatar: user.avatar,
role: user.role,
department: user.department,
},
})
response.cookies.set(cookieName, sessionToken, {
httpOnly: true,
sameSite,
@ -335,6 +310,32 @@ async function createAuthenticatedResponse(
})
}
}
async function createAuthenticatedResponse(
request: NextRequest,
user: DingtalkNextAuthUser,
options?: {
cookieSameSite?: 'lax' | 'none'
},
) {
const response = NextResponse.json({
success: true,
needCompleteProfile: false,
message: '登录成功',
sessionCreated: true,
user: {
id: user.id,
username: user.username,
email: user.email,
realName: user.realName,
avatar: user.avatar,
role: user.role,
department: user.department,
},
})
await setDingtalkAuthenticatedSessionCookies(response, request, user, options)
return response
}

View File

@ -37,6 +37,8 @@ export function parseDingtalkOAuthState(value: unknown) {
origin: '',
redirectPath: '/',
source: '',
nonce: '',
handoffId: '',
}
}
@ -52,12 +54,16 @@ export function parseDingtalkOAuthState(value: unknown) {
origin: typeof data?.origin === 'string' ? data.origin : '',
redirectPath: normalizeInternalRedirectPath(data?.redirect),
source: typeof data?.source === 'string' ? data.source : '',
nonce: typeof data?.nonce === 'string' ? data.nonce : '',
handoffId: typeof data?.handoffId === 'string' ? data.handoffId : '',
}
} catch {
return {
origin: '',
redirectPath: '/',
source: '',
nonce: '',
handoffId: '',
}
}
}

View File

@ -0,0 +1,47 @@
const DEFAULT_DINGTALK_RETURN_ORIGINS = ['http://192.168.58.20:4320']
function normalizeHttpOrigin(value: unknown): string | null {
const raw = String(value ?? '').trim()
if (!raw) return null
try {
const parsed = new URL(raw)
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return null
}
if (parsed.username || parsed.password || parsed.pathname !== '/' || parsed.search || parsed.hash) {
return null
}
return parsed.origin
} catch {
return null
}
}
function parseConfiguredOrigins(value: string | undefined): string[] {
return String(value || '')
.split(',')
.map((item) => normalizeHttpOrigin(item))
.filter((item): item is string => Boolean(item))
}
/**
* 钉钉必须回到公网完成 OAuth;这里仅定义 OAuth 完成后允许交接会话的内网入口。
*
* 生产环境通过 DINGTALK_INTERNAL_RETURN_ORIGINS 显式配置。未配置时仅保留当前稳定内网入口,
* 防止把 state 中任意地址当成跳转目标。
*/
export function getDingtalkReturnOrigins(): string[] {
const configured = parseConfiguredOrigins(process.env.DINGTALK_INTERNAL_RETURN_ORIGINS)
const candidates = configured.length > 0 ? configured : DEFAULT_DINGTALK_RETURN_ORIGINS
return Array.from(new Set(candidates))
}
export function getTrustedDingtalkReturnOrigin(value: unknown): string | null {
const origin = normalizeHttpOrigin(value)
if (!origin) return null
return getDingtalkReturnOrigins().includes(origin) ? origin : null
}

View File

@ -1,13 +1,20 @@
import { randomUUID } from 'crypto'
import { createHash, randomUUID, timingSafeEqual } from 'crypto'
type DingtalkSessionTicket = {
id: string
redirectPath: string
cookies: string[]
cookies?: string[]
userId?: string | null
targetOrigin?: string | null
handoffId?: string | null
handoffBindingHash?: string | null
nonce?: string | null
expiresAt: number
}
const TICKET_TTL_MS = 60_000
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() {
@ -19,40 +26,169 @@ function cleanupExpiredTickets() {
})
}
export function createDingtalkSessionTicket(input: {
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[]
cookies?: string[]
userId?: string | null
targetOrigin?: string | null
handoffId?: string | null
handoffBindingHash?: string | null
nonce?: string | null
}) {
cleanupExpiredTickets()
if (!input.userId && (!Array.isArray(input.cookies) || input.cookies.length === 0)) {
throw new Error('创建钉钉登录会话交接票据时缺少会话内容')
}
const id = randomUUID()
tickets.set(id, {
const ticket: DingtalkSessionTicket = {
id,
redirectPath: input.redirectPath,
cookies: input.cookies,
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 function consumeDingtalkSessionTicket(id: string | null | undefined) {
cleanupExpiredTickets()
export async function consumeDingtalkSessionTicket(
id: string | null | undefined,
options?: {
expectedTargetOrigin?: string | null
expectedHandoffBinding?: string | null
},
) {
const normalizedId = String(id || '').trim()
if (!normalizedId) {
return null
}
const ticket = tickets.get(normalizedId)
tickets.delete(normalizedId)
if (shouldUseRedisTicketStore()) {
const { redis } = await import('@/lib/upload-sessions-redis')
const key = getRedisKey(normalizedId)
const raw = await redis.get(key)
if (!raw) return null
if (!ticket || ticket.expiresAt <= Date.now()) {
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,
cookies: ticket.cookies || [],
userId: ticket.userId || null,
targetOrigin: ticket.targetOrigin || null,
handoffId: ticket.handoffId || null,
}
}

View File

@ -58,6 +58,7 @@ $targetConfig = @{
PublicAppUrl = 'https://sc.jiansun.vip'
CorsAllowedOrigins = 'https://sc.jiansun.vip,http://192.168.58.20:4320'
DingtalkCallbackOrigin = 'https://sc.jiansun.vip'
DingtalkInternalReturnOrigins = 'http://192.168.58.20:4320'
S3PublicDomain = 'https://s3.jiansun.vip/gongxiangpan'
}
shadow = @{
@ -67,6 +68,7 @@ $targetConfig = @{
PublicAppUrl = 'http://192.168.58.20:5320'
CorsAllowedOrigins = 'http://192.168.58.20:5320'
DingtalkCallbackOrigin = 'http://192.168.58.20:5320'
DingtalkInternalReturnOrigins = 'http://192.168.58.20:5320'
S3PublicDomain = 'https://s3.jiansun.vip/gongxiangpan'
}
}
@ -282,6 +284,7 @@ $tokens = @{
'__PUBLIC_APP_URL__' = ConvertTo-ShellSingleQuoted $cfg.PublicAppUrl
'__CORS_ALLOWED_ORIGINS__' = ConvertTo-ShellSingleQuoted $cfg.CorsAllowedOrigins
'__DINGTALK_CALLBACK_ORIGIN__' = ConvertTo-ShellSingleQuoted $cfg.DingtalkCallbackOrigin
'__DINGTALK_INTERNAL_RETURN_ORIGINS__' = ConvertTo-ShellSingleQuoted $cfg.DingtalkInternalReturnOrigins
'__S3_PUBLIC_DOMAIN__' = ConvertTo-ShellSingleQuoted $cfg.S3PublicDomain
'__DOWNLOAD_TOKEN_SECRET__' = ConvertTo-ShellSingleQuoted $DownloadTokenSecret
'__DRY_RUN__' = ConvertTo-ShellSingleQuoted ($(if ($DryRun) { '1' } else { '0' }))
@ -314,6 +317,7 @@ ENV_FILE=__ENV_FILE__
PUBLIC_APP_URL=__PUBLIC_APP_URL__
CORS_ALLOWED_ORIGINS=__CORS_ALLOWED_ORIGINS__
DINGTALK_CALLBACK_ORIGIN=__DINGTALK_CALLBACK_ORIGIN__
DINGTALK_INTERNAL_RETURN_ORIGINS=__DINGTALK_INTERNAL_RETURN_ORIGINS__
S3_PUBLIC_DOMAIN=__S3_PUBLIC_DOMAIN__
DOWNLOAD_TOKEN_SECRET=__DOWNLOAD_TOKEN_SECRET__
DRY_RUN=__DRY_RUN__
@ -516,6 +520,7 @@ start_container() {
-e "APP_PUBLIC_URL=$PUBLIC_APP_URL"
-e "CORS_ALLOWED_ORIGINS=$CORS_ALLOWED_ORIGINS"
-e "NEXT_PUBLIC_DINGTALK_CALLBACK_ORIGIN=$DINGTALK_CALLBACK_ORIGIN"
-e "DINGTALK_INTERNAL_RETURN_ORIGINS=$DINGTALK_INTERNAL_RETURN_ORIGINS"
-e "S3_PUBLIC_DOMAIN=$S3_PUBLIC_DOMAIN"
-e "DISABLE_CACHE=true"
-e "DEPLOYMENT_VERSION=$deploy_version"