diff --git a/app/dingtalk-entry/route.ts b/app/dingtalk-entry/route.ts index 037c38f..61579f2 100644 --- a/app/dingtalk-entry/route.ts +++ b/app/dingtalk-entry/route.ts @@ -4,7 +4,7 @@ import { getAuthSessionCookieCandidates, resolveSecureAuthCookieFromRequest, } from '@/lib/auth-cookie' -import { buildDingtalkOAuthUrl } from '@/lib/dingtalk-oauth' +import { DINGTALK_DIRECT_LOGIN_SOURCE, buildDingtalkOAuthUrl } from '@/lib/dingtalk-oauth' import { getPublicAppOrigin } from '@/lib/public-app-url' import { normalizeInternalRedirectPath } from '@/lib/safe-redirect' @@ -36,5 +36,6 @@ export async function GET(request: NextRequest) { return NextResponse.redirect(buildDingtalkOAuthUrl({ redirectPath, callbackPath: '/dingtalk-callback', + source: DINGTALK_DIRECT_LOGIN_SOURCE, })) } diff --git a/lib/dingtalk-oauth.ts b/lib/dingtalk-oauth.ts index 86c9519..137059c 100644 --- a/lib/dingtalk-oauth.ts +++ b/lib/dingtalk-oauth.ts @@ -2,6 +2,7 @@ import { getPublicAppOrigin } from '@/lib/public-app-url' import { normalizeInternalRedirectPath } from '@/lib/safe-redirect' const FALLBACK_DINGTALK_CLIENT_ID = 'ding1bs1gxwrcfu16x0f' +export const DINGTALK_DIRECT_LOGIN_SOURCE = 'work_notice_direct' export function getDingtalkOAuthClientId() { return ( @@ -12,11 +13,21 @@ export function getDingtalkOAuthClientId() { ) } -export function encodeDingtalkOAuthState(redirectPath: unknown, origin = getPublicAppOrigin()) { - return JSON.stringify({ +export function encodeDingtalkOAuthState( + redirectPath: unknown, + origin = getPublicAppOrigin(), + options?: { source?: string }, +) { + const payload: Record = { origin, redirect: normalizeInternalRedirectPath(redirectPath), - }) + } + + if (options?.source) { + payload.source = options.source + } + + return JSON.stringify(payload) } export function parseDingtalkOAuthState(value: unknown) { @@ -25,6 +36,7 @@ export function parseDingtalkOAuthState(value: unknown) { return { origin: '', redirectPath: '/', + source: '', } } @@ -39,11 +51,13 @@ export function parseDingtalkOAuthState(value: unknown) { return { origin: typeof data?.origin === 'string' ? data.origin : '', redirectPath: normalizeInternalRedirectPath(data?.redirect), + source: typeof data?.source === 'string' ? data.source : '', } } catch { return { origin: '', redirectPath: '/', + source: '', } } } @@ -52,10 +66,12 @@ export function buildDingtalkOAuthUrl({ redirectPath, callbackPath = '/api/dingtalk/callback', prompt, + source, }: { redirectPath: unknown callbackPath?: string prompt?: string + source?: string }) { const publicOrigin = getPublicAppOrigin() const redirectUrl = `${publicOrigin}${callbackPath}` @@ -64,7 +80,7 @@ export function buildDingtalkOAuthUrl({ client_id: getDingtalkOAuthClientId(), scope: 'openid', response_type: 'code', - state: encodeDingtalkOAuthState(redirectPath, publicOrigin), + state: encodeDingtalkOAuthState(redirectPath, publicOrigin, { source }), }) if (prompt) { diff --git a/middleware.ts b/middleware.ts index 7252f79..becafb5 100644 --- a/middleware.ts +++ b/middleware.ts @@ -5,10 +5,90 @@ import { getAuthSessionCookieCandidates, resolveSecureAuthCookieFromRequest, } from '@/lib/auth-cookie' +import { + DINGTALK_DIRECT_LOGIN_SOURCE, + parseDingtalkOAuthState, +} from '@/lib/dingtalk-oauth' +import { getPublicAppOrigin } from '@/lib/public-app-url' +import { normalizeInternalRedirectPath } from '@/lib/safe-redirect' const API_CORS_ALLOWED_HEADERS = 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, X-API-Key, Authorization, Cookie' +function buildLoginFallbackPath(redirectPath: unknown, error?: string) { + const target = normalizeInternalRedirectPath(redirectPath) + const url = new URL('/login', getPublicAppOrigin()) + url.searchParams.set('redirect', target) + if (error) { + url.searchParams.set('dingtalk_error', error) + } + return url +} + +function getInternalDingtalkLoginUrl() { + const port = process.env.PORT || '3000' + return `http://127.0.0.1:${port}/api/dingtalk/login` +} + +function appendSetCookie(target: NextResponse, source: Response) { + const setCookie = source.headers.get('set-cookie') + if (setCookie) { + target.headers.append('set-cookie', setCookie) + } +} + +function getForwardedLoginHeaders(request: NextRequest) { + const publicUrl = new URL(getPublicAppOrigin()) + return { + 'content-type': 'application/json', + 'x-forwarded-proto': publicUrl.protocol.replace(':', ''), + 'x-forwarded-host': publicUrl.host, + 'x-real-ip': request.headers.get('x-real-ip') || '', + 'x-forwarded-for': request.headers.get('x-forwarded-for') || '', + } +} + +async function handleDingtalkDirectLoginCallback(request: NextRequest) { + const code = request.nextUrl.searchParams.get('code') || request.nextUrl.searchParams.get('authCode') + const state = request.nextUrl.searchParams.get('state') || '' + const parsedState = parseDingtalkOAuthState(state) + + if (parsedState.source !== DINGTALK_DIRECT_LOGIN_SOURCE) { + return null + } + + const publicOrigin = getPublicAppOrigin() + const redirectPath = parsedState.redirectPath + + if (!code) { + return NextResponse.redirect(buildLoginFallbackPath(redirectPath, '缺少钉钉授权码')) + } + + try { + const loginResponse = await fetch(getInternalDingtalkLoginUrl(), { + method: 'POST', + headers: getForwardedLoginHeaders(request), + body: JSON.stringify({ code, state }), + cache: 'no-store', + }) + const data = await loginResponse.json().catch(() => null) + + if (!loginResponse.ok || !data?.success || data.needCompleteProfile) { + return NextResponse.redirect( + buildLoginFallbackPath(redirectPath, data?.message || '钉钉登录失败'), + ) + } + + const redirectResponse = NextResponse.redirect(new URL(redirectPath, publicOrigin)) + appendSetCookie(redirectResponse, loginResponse) + redirectResponse.headers.set('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') + return redirectResponse + } catch (error) { + console.error('[Dingtalk] 通知直达服务端登录失败:', error) + return NextResponse.redirect(buildLoginFallbackPath(redirectPath, '钉钉登录失败')) + } +} + // 注意:不在middleware中启动内存监控 // middleware使用Edge Runtime,不支持Node.js API // 内存监控会在lib/memory-monitor.ts模块加载时自动启动(仅生产环境) @@ -43,6 +123,13 @@ export async function middleware(request: NextRequest) { return response } + if (pathname === '/dingtalk-callback') { + const dingtalkDirectLoginResponse = await handleDingtalkDirectLoginCallback(request) + if (dingtalkDirectLoginResponse) { + return dingtalkDirectLoginResponse + } + } + // 处理需要认证的页面 if (pathname === '/' || pathname.startsWith('/admin')) { const preferredSecureCookie = resolveSecureAuthCookieFromRequest(request) @@ -136,5 +223,5 @@ export async function middleware(request: NextRequest) { } export const config = { - matcher: ['/', '/admin/:path*', '/api/:path*'], + matcher: ['/', '/admin/:path*', '/api/:path*', '/dingtalk-callback'], }