mirror of
https://github.com/langgenius/dify.git
synced 2026-07-21 02:28:30 +08:00
fix(auth): keep login redirects on current deployment (#39013)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
This commit is contained in:
parent
6a511da325
commit
e5b7281eb9
@ -8,7 +8,6 @@ import {
|
||||
} from '@/app/(shareLayout)/webapp-signin/login-redirect'
|
||||
import AppUnavailable from '@/app/components/base/app-unavailable'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { IS_CLOUD_EDITION } from '@/config'
|
||||
import { useWebAppStore } from '@/context/web-app-context'
|
||||
import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { fetchAccessToken } from '@/service/share'
|
||||
@ -48,7 +47,7 @@ function Splash({ children }: PropsWithChildren) {
|
||||
const loginRedirect = resolveWebAppLoginRedirect(redirectUrl, window.location.origin)
|
||||
const effectiveShareCode = loginRedirect?.appCode || shareCode
|
||||
if (!effectiveShareCode || (isWebAppSigninPath(pathname) && !loginRedirect)) {
|
||||
replaceLoginRedirect(getClientLoginFallback(IS_CLOUD_EDITION), router.replace, basePath)
|
||||
replaceLoginRedirect(getClientLoginFallback(), router.replace, basePath)
|
||||
return
|
||||
}
|
||||
|
||||
@ -62,7 +61,7 @@ function Splash({ children }: PropsWithChildren) {
|
||||
const loginRedirect = resolveWebAppLoginRedirect(redirectUrl, window.location.origin)
|
||||
const isSigninRoute = isWebAppSigninPath(pathname)
|
||||
if ((redirectUrl !== null && !loginRedirect) || (isSigninRoute && !loginRedirect)) {
|
||||
replaceLoginRedirect(getClientLoginFallback(IS_CLOUD_EDITION), router.replace, basePath)
|
||||
replaceLoginRedirect(getClientLoginFallback(), router.replace, basePath)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@ -8,7 +8,6 @@ import { useTranslation } from 'react-i18next'
|
||||
import { resolveWebAppLoginRedirect } from '@/app/(shareLayout)/webapp-signin/login-redirect'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Countdown from '@/app/components/signin/countdown'
|
||||
import { IS_CLOUD_EDITION } from '@/config'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import { useWebAppStore } from '@/context/web-app-context'
|
||||
import { useRouter, useSearchParams } from '@/next/navigation'
|
||||
@ -35,13 +34,13 @@ export default function CheckCode() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!resolveWebAppLoginRedirect(redirectUrl, window.location.origin))
|
||||
replaceLoginRedirect(getClientLoginFallback(IS_CLOUD_EDITION), router.replace, basePath)
|
||||
replaceLoginRedirect(getClientLoginFallback(), router.replace, basePath)
|
||||
}, [redirectUrl, router])
|
||||
|
||||
const verify = async () => {
|
||||
const loginRedirect = resolveWebAppLoginRedirect(redirectUrl, window.location.origin)
|
||||
if (!loginRedirect) {
|
||||
replaceLoginRedirect(getClientLoginFallback(IS_CLOUD_EDITION), router.replace, basePath)
|
||||
replaceLoginRedirect(getClientLoginFallback(), router.replace, basePath)
|
||||
return
|
||||
}
|
||||
try {
|
||||
@ -89,7 +88,7 @@ export default function CheckCode() {
|
||||
const resendCode = async () => {
|
||||
const loginRedirect = resolveWebAppLoginRedirect(redirectUrl, window.location.origin)
|
||||
if (!loginRedirect) {
|
||||
replaceLoginRedirect(getClientLoginFallback(IS_CLOUD_EDITION), router.replace, basePath)
|
||||
replaceLoginRedirect(getClientLoginFallback(), router.replace, basePath)
|
||||
return
|
||||
}
|
||||
try {
|
||||
|
||||
@ -6,7 +6,6 @@ import { useCallback, useEffect } from 'react'
|
||||
import { resolveWebAppLoginRedirect } from '@/app/(shareLayout)/webapp-signin/login-redirect'
|
||||
import AppUnavailable from '@/app/components/base/app-unavailable'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { IS_CLOUD_EDITION } from '@/config'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { SSOProtocol } from '@/features/system-features/constants'
|
||||
import { useRouter, useSearchParams } from '@/next/navigation'
|
||||
@ -29,7 +28,7 @@ const ExternalMemberSSOAuth = () => {
|
||||
const handleSSOLogin = useCallback(async () => {
|
||||
const loginRedirect = resolveWebAppLoginRedirect(redirectUrl, window.location.origin)
|
||||
if (!loginRedirect) {
|
||||
replaceLoginRedirect(getClientLoginFallback(IS_CLOUD_EDITION), router.replace, basePath)
|
||||
replaceLoginRedirect(getClientLoginFallback(), router.replace, basePath)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { resolveWebAppLoginRedirect } from '@/app/(shareLayout)/webapp-signin/login-redirect'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { COUNT_DOWN_TIME_MS, useSetCountdownLeftTime } from '@/app/components/signin/storage'
|
||||
import { emailRegex, IS_CLOUD_EDITION } from '@/config'
|
||||
import { emailRegex } from '@/config'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import { useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { sendWebAppEMailLoginCode } from '@/service/common'
|
||||
@ -27,13 +27,13 @@ export default function MailAndCodeAuth() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!resolveWebAppLoginRedirect(redirectUrl, window.location.origin))
|
||||
replaceLoginRedirect(getClientLoginFallback(IS_CLOUD_EDITION), router.replace, basePath)
|
||||
replaceLoginRedirect(getClientLoginFallback(), router.replace, basePath)
|
||||
}, [redirectUrl, router])
|
||||
|
||||
const handleGetEMailVerificationCode = async () => {
|
||||
const loginRedirect = resolveWebAppLoginRedirect(redirectUrl, window.location.origin)
|
||||
if (!loginRedirect) {
|
||||
replaceLoginRedirect(getClientLoginFallback(IS_CLOUD_EDITION), router.replace, basePath)
|
||||
replaceLoginRedirect(getClientLoginFallback(), router.replace, basePath)
|
||||
return
|
||||
}
|
||||
try {
|
||||
|
||||
@ -6,7 +6,7 @@ import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { resolveWebAppLoginRedirect } from '@/app/(shareLayout)/webapp-signin/login-redirect'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { emailRegex, IS_CLOUD_EDITION } from '@/config'
|
||||
import { emailRegex } from '@/config'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import { useWebAppStore } from '@/context/web-app-context'
|
||||
import Link from '@/next/link'
|
||||
@ -39,13 +39,13 @@ export default function MailAndPasswordAuth({ isEmailSetup }: MailAndPasswordAut
|
||||
|
||||
useEffect(() => {
|
||||
if (!resolveWebAppLoginRedirect(redirectUrl, window.location.origin))
|
||||
replaceLoginRedirect(getClientLoginFallback(IS_CLOUD_EDITION), router.replace, basePath)
|
||||
replaceLoginRedirect(getClientLoginFallback(), router.replace, basePath)
|
||||
}, [redirectUrl, router])
|
||||
|
||||
const handleEmailPasswordLogin = async () => {
|
||||
const loginRedirect = resolveWebAppLoginRedirect(redirectUrl, window.location.origin)
|
||||
if (!loginRedirect) {
|
||||
replaceLoginRedirect(getClientLoginFallback(IS_CLOUD_EDITION), router.replace, basePath)
|
||||
replaceLoginRedirect(getClientLoginFallback(), router.replace, basePath)
|
||||
return
|
||||
}
|
||||
if (!email) {
|
||||
|
||||
@ -5,7 +5,6 @@ import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { resolveWebAppLoginRedirect } from '@/app/(shareLayout)/webapp-signin/login-redirect'
|
||||
import { Lock01 } from '@/app/components/base/icons/src/vender/solid/security'
|
||||
import { IS_CLOUD_EDITION } from '@/config'
|
||||
import { SSOProtocol } from '@/features/system-features/constants'
|
||||
import { useRouter, useSearchParams } from '@/next/navigation'
|
||||
import {
|
||||
@ -32,13 +31,13 @@ function SSOAuth({ protocol }: SSOAuthProps) {
|
||||
|
||||
useEffect(() => {
|
||||
if (!resolveWebAppLoginRedirect(redirectUrl, window.location.origin))
|
||||
replaceLoginRedirect(getClientLoginFallback(IS_CLOUD_EDITION), router.replace, basePath)
|
||||
replaceLoginRedirect(getClientLoginFallback(), router.replace, basePath)
|
||||
}, [redirectUrl, router])
|
||||
|
||||
const handleSSOLogin = () => {
|
||||
const loginRedirect = resolveWebAppLoginRedirect(redirectUrl, window.location.origin)
|
||||
if (!loginRedirect) {
|
||||
replaceLoginRedirect(getClientLoginFallback(IS_CLOUD_EDITION), router.replace, basePath)
|
||||
replaceLoginRedirect(getClientLoginFallback(), router.replace, basePath)
|
||||
return
|
||||
}
|
||||
setIsLoading(true)
|
||||
|
||||
@ -5,7 +5,6 @@ import { useCallback, useEffect, useSyncExternalStore } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppUnavailable from '@/app/components/base/app-unavailable'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { IS_CLOUD_EDITION } from '@/config'
|
||||
import { useWebAppStore } from '@/context/web-app-context'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
@ -35,7 +34,7 @@ function WebSSOForm() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!resolveWebAppLoginRedirect(redirectUrl, window.location.origin))
|
||||
replaceLoginRedirect(getClientLoginFallback(IS_CLOUD_EDITION), router.replace, basePath)
|
||||
replaceLoginRedirect(getClientLoginFallback(), router.replace, basePath)
|
||||
}, [redirectUrl, router])
|
||||
|
||||
const getSigninUrl = useCallback(() => {
|
||||
|
||||
@ -244,41 +244,39 @@ describe('auth refresh route', () => {
|
||||
expect(response.headers.get('location')).toBe('/signin?redirect_url=%2F')
|
||||
})
|
||||
|
||||
it('should use the Cloud home as the fallback after a successful refresh', async () => {
|
||||
it('should keep a Cloud staging fallback on the current deployment after refresh', async () => {
|
||||
mocks.isCloudEdition = true
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 200 })))
|
||||
const { GET } = await import('../route')
|
||||
|
||||
const response = await GET(
|
||||
createRequest(
|
||||
'https://cloud.dify.ai/auth/refresh?redirect_url=https%3A%2F%2Fevil.example',
|
||||
'https://saas.dify.dev/auth/refresh?redirect_url=https%3A%2F%2Fevil.example',
|
||||
'refresh_token=old-refresh',
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(303)
|
||||
expect(response.headers.get('location')).toBe('https://cloud.dify.ai/')
|
||||
expect(response.headers.get('location')).toBe('/')
|
||||
})
|
||||
|
||||
it('should carry the Cloud fallback through signin when refresh fails', async () => {
|
||||
it('should carry the current Cloud deployment fallback through signin when refresh fails', async () => {
|
||||
mocks.isCloudEdition = true
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 401 })))
|
||||
const { GET } = await import('../route')
|
||||
|
||||
const response = await GET(
|
||||
createRequest(
|
||||
'https://cloud.dify.ai/auth/refresh?redirect_url=https%3A%2F%2Fevil.example',
|
||||
'https://saas.dify.dev/auth/refresh?redirect_url=https%3A%2F%2Fevil.example',
|
||||
'refresh_token=expired',
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(303)
|
||||
expect(response.headers.get('location')).toBe(
|
||||
'/signin?redirect_url=https%3A%2F%2Fcloud.dify.ai%2F',
|
||||
)
|
||||
expect(response.headers.get('location')).toBe('/signin?redirect_url=%2F')
|
||||
})
|
||||
|
||||
it('should use the Cloud home when a trusted absolute target loops back to auth refresh', async () => {
|
||||
it('should use the current deployment home when a trusted target loops back to auth refresh', async () => {
|
||||
mocks.isCloudEdition = true
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 200 })))
|
||||
const { GET } = await import('../route')
|
||||
@ -291,7 +289,7 @@ describe('auth refresh route', () => {
|
||||
)
|
||||
|
||||
expect(response.status).toBe(303)
|
||||
expect(response.headers.get('location')).toBe('https://cloud.dify.ai/')
|
||||
expect(response.headers.get('location')).toBe('/')
|
||||
})
|
||||
|
||||
it.each(['/auth/refresh/', '/auth/%72efresh'])(
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import type { LoginRedirectTarget } from '@/utils/login-redirect'
|
||||
import { IS_CLOUD_EDITION } from '@/config'
|
||||
import { resolveServerConsoleApiUrl } from '@/service/server'
|
||||
import { getServerLoginFallback, resolveLoginRedirectTarget } from '@/utils/login-redirect'
|
||||
import { basePath } from '@/utils/var'
|
||||
@ -38,7 +37,7 @@ const addBasePathToInternalTarget = (target: LoginRedirectTarget): LoginRedirect
|
||||
const resolveSafeRedirectTarget = (request: Request): LoginRedirectTarget => {
|
||||
const requestUrl = new URL(request.url)
|
||||
const redirectUrl = requestUrl.searchParams.get('redirect_url')
|
||||
const fallback = getServerLoginFallback(IS_CLOUD_EDITION, basePath)
|
||||
const fallback = getServerLoginFallback(basePath)
|
||||
|
||||
if (!redirectUrl) return fallback
|
||||
|
||||
|
||||
@ -20,6 +20,11 @@ vi.mock('@/app/components/base/amplitude', () => ({
|
||||
trackEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/config', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/config')>()),
|
||||
IS_CLOUD_EDITION: true,
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
back: navigationMocks.back,
|
||||
@ -79,6 +84,10 @@ describe('CheckCode', () => {
|
||||
vi.mocked(emailLoginWithCode).mockResolvedValue({ result: 'success' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('Post-login profile bootstrap', () => {
|
||||
it('should resolve an inactive profile query before navigating to the console home', async () => {
|
||||
const user = userEvent.setup()
|
||||
@ -129,5 +138,44 @@ describe('CheckCode', () => {
|
||||
})
|
||||
expect(queryClient.getQueryState(profileQueryKey)?.status).toBe('success')
|
||||
})
|
||||
|
||||
it('should keep a Cloud verification-code login on the current deployment', async () => {
|
||||
const user = userEvent.setup()
|
||||
const queryClient = createQueryClient()
|
||||
const locationReplace = vi.fn()
|
||||
navigationMocks.searchParams = new URLSearchParams({
|
||||
email: 'hanxujiang%2B4%40dify.ai',
|
||||
token: 'email-login-token',
|
||||
})
|
||||
serviceBaseMocks.get.mockResolvedValue(
|
||||
new Response(JSON.stringify(accountProfile), {
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-env': 'DEVELOPMENT',
|
||||
'x-version': '1.0.0',
|
||||
},
|
||||
status: 200,
|
||||
}),
|
||||
)
|
||||
vi.stubGlobal('location', {
|
||||
...window.location,
|
||||
origin: 'https://saas.dify.dev',
|
||||
replace: locationReplace,
|
||||
} as unknown as Location)
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CheckCode />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
await user.type(screen.getByLabelText('login.checkCode.verificationCode'), '123456')
|
||||
await user.click(screen.getByRole('button', { name: 'login.checkCode.verify' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(navigationMocks.replace).toHaveBeenCalledWith('/')
|
||||
})
|
||||
expect(locationReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import type { ReadonlyURLSearchParams } from '@/next/navigation'
|
||||
import { IS_CLOUD_EDITION } from '@/config'
|
||||
import { getClientLoginFallback, resolveLoginRedirectTarget } from '@/utils/login-redirect'
|
||||
|
||||
const REDIRECT_URL_KEY = 'redirect_url'
|
||||
@ -75,7 +74,7 @@ function getDeviceRedirect(): string | null {
|
||||
|
||||
export function resolvePostLoginRedirect(searchParams?: ReadonlyURLSearchParams) {
|
||||
const currentOrigin = typeof window === 'undefined' ? undefined : window.location.origin
|
||||
const fallback = getClientLoginFallback(IS_CLOUD_EDITION)
|
||||
const fallback = getClientLoginFallback()
|
||||
|
||||
if (searchParams?.has(REDIRECT_URL_KEY)) {
|
||||
return (
|
||||
|
||||
@ -143,30 +143,19 @@ describe('resolveLoginRedirectTarget', () => {
|
||||
})
|
||||
|
||||
describe('login redirect fallbacks', () => {
|
||||
it('should use the Cloud console home for the client Cloud fallback', () => {
|
||||
expect(getClientLoginFallback(true)).toEqual({
|
||||
kind: 'absolute',
|
||||
href: 'https://cloud.dify.ai/',
|
||||
it('should keep the client fallback on the current deployment', () => {
|
||||
expect(getClientLoginFallback()).toEqual({
|
||||
kind: 'internal',
|
||||
href: '/',
|
||||
})
|
||||
})
|
||||
|
||||
it('should use an internal root for the client self-hosted fallback', () => {
|
||||
expect(getClientLoginFallback(false)).toEqual({ kind: 'internal', href: '/' })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['', '/'],
|
||||
['/', '/'],
|
||||
['/console', '/console/'],
|
||||
['/console/', '/console/'],
|
||||
])('should include basePath %s in the server self-hosted fallback', (basePath, href) => {
|
||||
expect(getServerLoginFallback(false, basePath)).toEqual({ kind: 'internal', href })
|
||||
})
|
||||
|
||||
it('should use the Cloud console home for the server Cloud fallback', () => {
|
||||
expect(getServerLoginFallback(true, '/console')).toEqual({
|
||||
kind: 'absolute',
|
||||
href: 'https://cloud.dify.ai/',
|
||||
})
|
||||
])('should include basePath %s in the server deployment fallback', (basePath, href) => {
|
||||
expect(getServerLoginFallback(basePath)).toEqual({ kind: 'internal', href })
|
||||
})
|
||||
})
|
||||
|
||||
@ -7,7 +7,6 @@ type ResolveLoginRedirectOptions = {
|
||||
allowSameOriginAbsolute?: boolean
|
||||
}
|
||||
|
||||
const CLOUD_CONSOLE_HOME = 'https://cloud.dify.ai/'
|
||||
const INTERNAL_URL_BASE = 'https://login-redirect.invalid'
|
||||
const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001F\u007F]/
|
||||
const MALFORMED_PERCENT_PATTERN = /%(?![\dA-F]{2})/i
|
||||
@ -176,17 +175,11 @@ export function resolveLoginRedirectTarget(
|
||||
}
|
||||
}
|
||||
|
||||
export function getClientLoginFallback(isCloudEdition: boolean): LoginRedirectTarget {
|
||||
if (isCloudEdition) return { kind: 'absolute', href: CLOUD_CONSOLE_HOME }
|
||||
export function getClientLoginFallback(): LoginRedirectTarget {
|
||||
return { kind: 'internal', href: '/' }
|
||||
}
|
||||
|
||||
export function getServerLoginFallback(
|
||||
isCloudEdition: boolean,
|
||||
basePath: string,
|
||||
): LoginRedirectTarget {
|
||||
if (isCloudEdition) return { kind: 'absolute', href: CLOUD_CONSOLE_HOME }
|
||||
|
||||
export function getServerLoginFallback(basePath: string): LoginRedirectTarget {
|
||||
const normalizedBasePath = normalizeBasePath(basePath)
|
||||
return { kind: 'internal', href: normalizedBasePath ? `${normalizedBasePath}/` : '/' }
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user