支持钉钉通知免登录入口
This commit is contained in:
parent
222df24ba8
commit
05efe45050
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
33
app/dingtalk-entry/route.ts
Normal file
33
app/dingtalk-entry/route.ts
Normal 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))
|
||||
}
|
||||
@ -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">
|
||||
|
||||
25
lib/__tests__/safe-redirect.test.ts
Normal file
25
lib/__tests__/safe-redirect.test.ts
Normal 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',
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -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
25
lib/safe-redirect.ts
Normal 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)}`
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user