diff --git a/app/dingtalk-callback/page.tsx b/app/dingtalk-callback/page.tsx index 1f5c1d1b..91e09c0a 100644 --- a/app/dingtalk-callback/page.tsx +++ b/app/dingtalk-callback/page.tsx @@ -3,6 +3,7 @@ import { useEffect, useState, Suspense } from 'react' import { useRouter, useSearchParams } from 'next/navigation' import { signIn } from 'next-auth/react' +import { normalizeInternalRedirectPath } from '@/lib/safe-redirect' function DingtalkCallbackContent() { const router = useRouter() @@ -22,9 +23,11 @@ function DingtalkCallbackContent() { // 解析state参数,获取原始访问地址 let originUrl = '' + let redirectPath = '/' try { const stateData = JSON.parse(decodeURIComponent(state)) originUrl = stateData.origin || '' + redirectPath = normalizeInternalRedirectPath(stateData.redirect) } catch (e) { // state解析失败,忽略 } @@ -91,7 +94,8 @@ function DingtalkCallbackContent() { { type: 'dingtalk_login_success', userInfo: data.user, - originUrl: originUrl // 携带原始地址 + originUrl: originUrl, // 携带原始地址 + redirectUrl: redirectPath, }, '*' ) @@ -101,7 +105,8 @@ function DingtalkCallbackContent() { { type: 'dingtalk_login_success', userInfo: data.user, - originUrl: originUrl // 携带原始地址 + originUrl: originUrl, // 携带原始地址 + redirectUrl: redirectPath, }, '*' ) @@ -111,7 +116,7 @@ function DingtalkCallbackContent() { // 从内网来的:不在这里创建session,跳转回内网后由内网页面创建 setMessage('登录成功,正在返回内网...') // 将用户信息作为参数传递,让内网页面创建session - window.location.href = `${originUrl}/login?dingtalk_success=true&email=${encodeURIComponent(data.user.email)}` + window.location.href = `${originUrl}/login?dingtalk_success=true&email=${encodeURIComponent(data.user.email)}&redirect=${encodeURIComponent(redirectPath)}` } else { // 从外网来的:直接创建session并跳转 const signInResult = await signIn('credentials', { @@ -125,8 +130,7 @@ function DingtalkCallbackContent() { } setTimeout(() => { - router.push('/') - router.refresh() + window.location.href = redirectPath }, 1000) } } diff --git a/app/dingtalk-entry/route.ts b/app/dingtalk-entry/route.ts new file mode 100644 index 00000000..f738fefe --- /dev/null +++ b/app/dingtalk-entry/route.ts @@ -0,0 +1,33 @@ +import { NextResponse, type NextRequest } from 'next/server' +import { getToken } from 'next-auth/jwt' +import { + getAuthSessionCookieCandidates, + resolveSecureAuthCookieFromRequest, +} from '@/lib/auth-cookie' +import { buildDingtalkAutoLoginPath, normalizeInternalRedirectPath } from '@/lib/safe-redirect' + +async function resolveCurrentToken(request: NextRequest) { + const preferredSecureCookie = resolveSecureAuthCookieFromRequest(request) + const cookieCandidates = getAuthSessionCookieCandidates(preferredSecureCookie) + + for (const cookieName of cookieCandidates) { + const token = await getToken({ + req: request, + secret: process.env.NEXTAUTH_SECRET, + cookieName, + secureCookie: cookieName.startsWith('__Secure-'), + }) + + if (token) return token + } + + return null +} + +export async function GET(request: NextRequest) { + const redirectPath = normalizeInternalRedirectPath(request.nextUrl.searchParams.get('redirect')) + const token = await resolveCurrentToken(request) + const nextPath = token ? redirectPath : buildDingtalkAutoLoginPath(redirectPath) + + return NextResponse.redirect(new URL(nextPath, request.url)) +} diff --git a/app/login/page.tsx b/app/login/page.tsx index a3e93a74..9e40c122 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -8,6 +8,7 @@ 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 { normalizeInternalRedirectPath } from '@/lib/safe-redirect' const DEFAULT_LOGIN_LOGO_URL = '/api/favicon' @@ -30,6 +31,11 @@ export default function LoginPage() { const dingtalkCallbackOrigin = process.env.NEXT_PUBLIC_DINGTALK_CALLBACK_ORIGIN?.trim() || publicAppOrigin + const getLoginRedirectPath = () => { + if (typeof window === 'undefined') return '/' + return normalizeInternalRedirectPath(new URLSearchParams(window.location.search).get('redirect')) + } + // 加载系统设置 useEffect(() => { const loadSettings = async () => { @@ -65,15 +71,18 @@ export default function LoginPage() { const dingtalkRedirect = urlParams.get('dingtalk_redirect') if (dingtalkRedirect === 'true') { + const redirectPath = normalizeInternalRedirectPath(urlParams.get('redirect')) console.log('🔄 检测到内网钉钉登录请求,自动触发登录...') // 清理URL参数 - window.history.replaceState({}, '', '/login') + window.history.replaceState({}, '', `/login?redirect=${encodeURIComponent(redirectPath)}`) // 等待页面加载完成后触发钉钉登录 const timer = setTimeout(() => { - document.getElementById('dingtalk-login-btn')?.click() + handleDingtalkLogin({ redirectPath, useCurrentWindow: true }) }, 500) return () => clearTimeout(timer) } + // 自动钉钉入口只应在页面首次打开时判断一次,后续渲染不重复拉起授权。 + // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // 处理钉钉登录成功后的自动登录(内网场景) @@ -82,6 +91,7 @@ export default function LoginPage() { const urlParams = new URLSearchParams(window.location.search) const dingtalkSuccess = urlParams.get('dingtalk_success') const userEmail = urlParams.get('email') + const redirectPath = normalizeInternalRedirectPath(urlParams.get('redirect')) if (dingtalkSuccess === 'true' && userEmail) { setLoading(true) @@ -98,8 +108,7 @@ export default function LoginPage() { } else { // 清除 URL 参数并跳转到首页 window.history.replaceState({}, '', '/login') - router.push('/') - router.refresh() + window.location.href = `${window.location.origin}${redirectPath}` } } catch (error) { console.error('自动登录失败:', error) @@ -150,7 +159,7 @@ export default function LoginPage() { console.log('🔄 开始跳转到首页...') // 使用 window.location.href 强制跳转,确保跳转生效 // 使用完整的 URL 可以确保 cookie 正确传递 - window.location.href = window.location.origin + '/' + window.location.href = window.location.origin + getLoginRedirectPath() // 注意:跳转后不需要 setLoading(false),因为页面会刷新 return } else { @@ -166,20 +175,32 @@ export default function LoginPage() { } } - const handleDingtalkLogin = () => { + const handleDingtalkLogin = (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 }) + const stateData = JSON.stringify({ origin: currentOrigin, redirect: redirectPath }) - const dingtalkAuthUrl = `https://login.dingtalk.com/oauth2/auth?` + - `redirect_uri=${encodeURIComponent(redirectUrl)}` + - `&client_id=${DINGTALK_CLIENT_ID}` + - `&scope=openid` + - `&response_type=code` + - `&state=${encodeURIComponent(stateData)}` + - `&prompt=consent` + const params = new URLSearchParams({ + redirect_uri: redirectUrl, + client_id: DINGTALK_CLIENT_ID, + scope: 'openid', + response_type: 'code', + state: stateData, + }) + + if (!options?.useCurrentWindow) { + params.set('prompt', 'consent') + } + + const dingtalkAuthUrl = `https://login.dingtalk.com/oauth2/auth?${params.toString()}` + + if (options?.useCurrentWindow) { + window.location.href = dingtalkAuthUrl + return + } // 使用新窗口打开钉钉登录,避免iframe的cookie限制问题 // 新窗口可以正常访问cookie,解决"授权失败"的问题 @@ -224,16 +245,16 @@ export default function LoginPage() { await new Promise(resolve => setTimeout(resolve, 100)) // 登录成功,智能跳转 const originUrl = event.data.originUrl + const targetPath = normalizeInternalRedirectPath(event.data.redirectUrl ?? redirectPath) if (originUrl && originUrl.includes('192.168.')) { // 从内网发起的登录,跳回内网 - window.location.href = originUrl + window.location.href = `${originUrl}${targetPath}` } else if (window.location.hostname.includes('192.168.')) { // 当前在内网,刷新页面 - window.location.href = window.location.origin + window.location.href = `${window.location.origin}${targetPath}` } else { - // 外网访问,跳转到首页 - router.push('/') - router.refresh() + // 外网访问,跳转到原始业务页面 + window.location.href = `${window.location.origin}${targetPath}` } } } catch (error) { @@ -348,7 +369,7 @@ export default function LoginPage() {