修复钉钉通知直达登录态写入

This commit is contained in:
chenpei 2026-07-06 14:15:20 +08:00
parent b93d3cccda
commit c91143d975
3 changed files with 110 additions and 6 deletions

View File

@ -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,
}))
}

View File

@ -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<string, string> = {
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) {

View File

@ -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'],
}