支持钉钉通知免登录入口

This commit is contained in:
chenpei 2026-07-06 13:22:04 +08:00
parent 222df24ba8
commit 05efe45050
6 changed files with 135 additions and 26 deletions

View File

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

View File

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

View File

@ -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() {
<button
id="dingtalk-login-btn"
type="button"
onClick={handleDingtalkLogin}
onClick={() => handleDingtalkLogin()}
className="w-full flex items-center justify-center gap-3 px-4 py-3 border border-gray-300 rounded-xl shadow-sm bg-white hover:bg-gray-50 transition-colors"
>
<svg style={{ width: '28px', height: '28px' }} viewBox="0 0 1024 1024">

View File

@ -0,0 +1,25 @@
import {
buildDingtalkAutoLoginPath,
buildDingtalkEntryPath,
normalizeInternalRedirectPath,
} from '@/lib/safe-redirect'
describe('safe redirect helpers', () => {
test('只允许站内路径作为登录后跳转目标', () => {
expect(normalizeInternalRedirectPath('/performance-survey/survey/1?targetId=2')).toBe(
'/performance-survey/survey/1?targetId=2',
)
expect(normalizeInternalRedirectPath('https://evil.example/a')).toBe('/')
expect(normalizeInternalRedirectPath('//evil.example/a')).toBe('/')
expect(normalizeInternalRedirectPath('javascript:alert(1)')).toBe('/')
})
test('生成钉钉入口和自动登录入口时会编码原始业务页', () => {
expect(buildDingtalkEntryPath('/performance-survey?tab=todo')).toBe(
'/dingtalk-entry?redirect=%2Fperformance-survey%3Ftab%3Dtodo',
)
expect(buildDingtalkAutoLoginPath('/performance-survey/results/1')).toBe(
'/login?dingtalk_redirect=true&redirect=%2Fperformance-survey%2Fresults%2F1',
)
})
})

View File

@ -1,6 +1,7 @@
import { randomUUID } from 'crypto'
import { prisma } from '@/lib/prisma'
import { getPublicAppOrigin } from '@/lib/public-app-url'
import { buildDingtalkEntryPath } from '@/lib/safe-redirect'
import { sendDingtalkWorkNotice } from '@/lib/dingtalk-client'
const STATUS = {
@ -43,7 +44,7 @@ function truncateBody(value: string | null | undefined, limit = 2000) {
}
function getSurveyUrl(path: string) {
return `${getPublicAppOrigin()}${path}`
return `${getPublicAppOrigin()}${buildDingtalkEntryPath(path)}`
}
export function ensurePerformanceSurveyNotificationWorkerStarted() {

25
lib/safe-redirect.ts Normal file
View File

@ -0,0 +1,25 @@
export function normalizeInternalRedirectPath(value: unknown, fallback = '/') {
const raw = String(value ?? '').trim()
if (!raw) return fallback
if (!raw.startsWith('/') || raw.startsWith('//')) {
return fallback
}
try {
const parsed = new URL(raw, 'https://internal.local')
return `${parsed.pathname}${parsed.search}${parsed.hash}`
} catch {
return fallback
}
}
export function buildDingtalkAutoLoginPath(redirectPath: unknown) {
const target = normalizeInternalRedirectPath(redirectPath)
return `/login?dingtalk_redirect=true&redirect=${encodeURIComponent(target)}`
}
export function buildDingtalkEntryPath(redirectPath: unknown) {
const target = normalizeInternalRedirectPath(redirectPath)
return `/dingtalk-entry?redirect=${encodeURIComponent(target)}`
}