diff --git a/app/api/dingtalk/login/route.ts b/app/api/dingtalk/login/route.ts index 89c2486..cabc05a 100644 --- a/app/api/dingtalk/login/route.ts +++ b/app/api/dingtalk/login/route.ts @@ -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( diff --git a/app/api/dingtalk/session-ticket/route.ts b/app/api/dingtalk/session-ticket/route.ts index 78d3af6..8695ac9 100644 --- a/app/api/dingtalk/session-ticket/route.ts +++ b/app/api/dingtalk/session-ticket/route.ts @@ -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(/ @@ -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 } diff --git a/app/api/dingtalk/session-transfer/start/route.test.ts b/app/api/dingtalk/session-transfer/start/route.test.ts new file mode 100644 index 0000000..0018eee --- /dev/null +++ b/app/api/dingtalk/session-transfer/start/route.test.ts @@ -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() + }) +}) diff --git a/app/api/dingtalk/session-transfer/start/route.ts b/app/api/dingtalk/session-transfer/start/route.ts new file mode 100644 index 0000000..c25d47f --- /dev/null +++ b/app/api/dingtalk/session-transfer/start/route.ts @@ -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 +} diff --git a/app/dingtalk-callback/page.tsx b/app/dingtalk-callback/page.tsx index f8031ea..efbc4ed 100644 --- a/app/dingtalk-callback/page.tsx +++ b/app/dingtalk-callback/page.tsx @@ -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') } diff --git a/app/login/page.tsx b/app/login/page.tsx index 903677d..dc372a5 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -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)) diff --git a/docker-compose.yml b/docker-compose.yml index 99d3d42..0c6c47e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/lib/__tests__/dingtalk-login-handoff.test.ts b/lib/__tests__/dingtalk-login-handoff.test.ts new file mode 100644 index 0000000..99c9edd --- /dev/null +++ b/lib/__tests__/dingtalk-login-handoff.test.ts @@ -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() + }) +}) diff --git a/lib/__tests__/dingtalk-return-origin.test.ts b/lib/__tests__/dingtalk-return-origin.test.ts new file mode 100644 index 0000000..b50a75e --- /dev/null +++ b/lib/__tests__/dingtalk-return-origin.test.ts @@ -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() + }) +}) diff --git a/lib/__tests__/dingtalk-session-ticket.test.ts b/lib/__tests__/dingtalk-session-ticket.test.ts new file mode 100644 index 0000000..f4231cf --- /dev/null +++ b/lib/__tests__/dingtalk-session-ticket.test.ts @@ -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'], + }) + }) +}) diff --git a/lib/dingtalk-login-handoff.ts b/lib/dingtalk-login-handoff.ts new file mode 100644 index 0000000..bd04ce7 --- /dev/null +++ b/lib/dingtalk-login-handoff.ts @@ -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() + +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) +} diff --git a/lib/dingtalk-login.ts b/lib/dingtalk-login.ts index eedf2cd..a121ab8 100644 --- a/lib/dingtalk-login.ts +++ b/lib/dingtalk-login.ts @@ -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 } diff --git a/lib/dingtalk-oauth.ts b/lib/dingtalk-oauth.ts index d1d7946..d5f3a56 100644 --- a/lib/dingtalk-oauth.ts +++ b/lib/dingtalk-oauth.ts @@ -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: '', } } } diff --git a/lib/dingtalk-return-origin.ts b/lib/dingtalk-return-origin.ts new file mode 100644 index 0000000..7789b50 --- /dev/null +++ b/lib/dingtalk-return-origin.ts @@ -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 +} diff --git a/lib/dingtalk-session-ticket.ts b/lib/dingtalk-session-ticket.ts index c7cdaae..a661a8c 100644 --- a/lib/dingtalk-session-ticket.ts +++ b/lib/dingtalk-session-ticket.ts @@ -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() 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, } } diff --git a/scripts/git-incremental-deploy.ps1 b/scripts/git-incremental-deploy.ps1 index ffe0f3c..6fe3353 100644 --- a/scripts/git-incremental-deploy.ps1 +++ b/scripts/git-incremental-deploy.ps1 @@ -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"