fix(auth): validate account before accepting invitations (#39438)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
zyssyz123 2026-07-23 11:56:32 +08:00 committed by GitHub
parent 625ed2a0e7
commit 2b8e40af32
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 260 additions and 17 deletions

View File

@ -7,10 +7,13 @@ from configs import dify_config
from constants.languages import supported_language
from controllers.common.schema import query_params_from_model, register_schema_models
from controllers.console import console_ns
from controllers.console.auth.error import InvitationAccountMismatchError
from controllers.console.error import AccountInFreezeError, AlreadyActivateError
from extensions.ext_database import db
from libs.datetime_utils import naive_utc_now
from libs.helper import EmailStr, timezone
from libs.login import current_account_with_tenant
from libs.token import extract_access_token
from models import AccountStatus
from models.account import TenantAccountJoin, TenantAccountRole
from services.account_service import RegisterService, TenantService
@ -136,6 +139,12 @@ class ActivateApi(Resource):
)
@console_ns.response(400, "Already activated or invalid token")
def post(self):
"""Accept an invitation without letting an existing session act for another account.
Token-only activation remains available for legacy clients. When the request already
carries a console session, that session must belong to the account encoded in the
invitation before the token is consumed or tenant membership is changed.
"""
args = ActivatePayload.model_validate(console_ns.payload)
normalized_request_email = args.email.lower() if args.email else None
@ -146,6 +155,11 @@ class ActivateApi(Resource):
raise AlreadyActivateError()
account = invitation["account"]
if extract_access_token(request):
current_account, _ = current_account_with_tenant()
if current_account.id != account.id:
raise InvitationAccountMismatchError()
if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(account.email):
raise AccountInFreezeError()

View File

@ -13,6 +13,12 @@ class InvalidEmailError(BaseHTTPException):
code = 400
class InvitationAccountMismatchError(BaseHTTPException):
error_code = "invitation_account_mismatch"
description = "This invitation was sent to another account. Please sign in with the invited account."
code = 403
class PasswordMismatchError(BaseHTTPException):
error_code = "password_mismatch"
description = "The passwords do not match."

View File

@ -260,7 +260,12 @@ Get account avatar url
| 200 | Success | **application/json**: [AccountResponse](#accountresponse)<br> |
### [POST] /activate
**Accept an invitation without letting an existing session act for another account**
Activate account with invitation token
Token-only activation remains available for legacy clients. When the request already
carries a console session, that session must belong to the account encoded in the
invitation before the token is consumed or tenant membership is changed.
#### Request Body

View File

@ -14,6 +14,7 @@ import pytest
from flask import Flask
from controllers.console.auth.activate import ActivateApi, ActivateCheckApi
from controllers.console.auth.error import InvitationAccountMismatchError
from controllers.console.error import AccountInFreezeError, AlreadyActivateError
from models.account import AccountStatus, TenantAccountRole
@ -202,6 +203,50 @@ class TestActivateApi:
with patch("controllers.console.auth.activate.TenantService.switch_tenant") as mock:
yield mock
@patch("controllers.console.auth.activate.TenantService.create_tenant_member")
@patch("controllers.console.auth.activate.RegisterService.get_invitation_with_case_fallback")
@patch("controllers.console.auth.activate.RegisterService.revoke_token")
@patch("controllers.console.auth.activate.current_account_with_tenant")
@patch("controllers.console.auth.activate.extract_access_token", return_value="access-token")
@patch("controllers.console.auth.activate.db")
def test_activation_rejects_invitation_for_different_authenticated_account(
self,
mock_db: MagicMock,
mock_extract_access_token: MagicMock,
mock_current_account_with_tenant: MagicMock,
mock_revoke_token: MagicMock,
mock_get_invitation: MagicMock,
mock_create_tenant_member: MagicMock,
app: Flask,
mock_invitation: MagicMock,
mock_account: MagicMock,
mock_switch_tenant: MagicMock,
):
"""A logged-in account cannot consume another account's invitation token."""
current_account = MagicMock()
current_account.id = "current-account-id"
mock_account.id = "invited-account-id"
mock_account.status = AccountStatus.ACTIVE
mock_invitation["data"]["requires_setup"] = False
mock_get_invitation.return_value = mock_invitation
mock_current_account_with_tenant.return_value = (current_account, "current-workspace-id")
with app.test_request_context(
"/activate",
method="POST",
json={
"token": "valid_token",
},
):
with pytest.raises(InvitationAccountMismatchError):
ActivateApi().post()
mock_extract_access_token.assert_called_once()
mock_revoke_token.assert_not_called()
mock_create_tenant_member.assert_not_called()
mock_switch_tenant.assert_not_called()
mock_db.session.scalar.assert_not_called()
@patch("controllers.console.auth.activate.RegisterService.get_invitation_if_token_valid")
@patch("controllers.console.auth.activate.RegisterService.revoke_token")
@patch("controllers.console.auth.activate.db")

View File

@ -29,15 +29,22 @@ export const check = {
}
/**
* Accept an invitation without letting an existing session act for another account
*
* Activate account with invitation token
* Token-only activation remains available for legacy clients. When the request already
* carries a console session, that session must belong to the account encoded in the
* invitation before the token is consumed or tenant membership is changed.
*/
export const post = oc
.route({
description: 'Activate account with invitation token',
description:
'Activate account with invitation token\nToken-only activation remains available for legacy clients. When the request already\ncarries a console session, that session must belong to the account encoded in the\ninvitation before the token is consumed or tenant membership is changed.',
inputStructure: 'detailed',
method: 'POST',
operationId: 'postActivate',
path: '/activate',
summary: 'Accept an invitation without letting an existing session act for another account',
tags: ['console'],
})
.input(z.object({ body: zPostActivateBody }))

View File

@ -53,6 +53,7 @@ const loggedInQueryResult = {
data: {
profile: {
id: 'account-id',
email: 'invitee@example.com',
},
},
error: null,
@ -77,6 +78,16 @@ const nonInviteQueryResult = {
data: undefined,
}
const mockQueryResults = (
profileResult: ReturnType<typeof useQuery>,
inviteResult: ReturnType<typeof useQuery>,
) => {
mockUseQuery.mockImplementation((options) => {
const queryKey = options.queryKey as readonly unknown[]
return (queryKey[0] === 'account' ? profileResult : inviteResult) as ReturnType<typeof useQuery>
})
}
describe('NormalForm', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -104,9 +115,10 @@ describe('NormalForm', () => {
it('should send logged-in visitors without a redirect target to the console home', async () => {
const searchParams = new URLSearchParams()
mockUseSearchParams.mockReturnValue(searchParams)
mockUseQuery
.mockReturnValueOnce(loggedInQueryResult as unknown as ReturnType<typeof useQuery>)
.mockReturnValueOnce(nonInviteQueryResult as unknown as ReturnType<typeof useQuery>)
mockQueryResults(
loggedInQueryResult as unknown as ReturnType<typeof useQuery>,
nonInviteQueryResult as unknown as ReturnType<typeof useQuery>,
)
render(<NormalForm />)
@ -119,9 +131,10 @@ describe('NormalForm', () => {
mockUseSearchParams.mockReturnValue(
new URLSearchParams('redirect_url=https%3A%2F%2Fgoogle.com'),
)
mockUseQuery
.mockReturnValueOnce(loggedInQueryResult as unknown as ReturnType<typeof useQuery>)
.mockReturnValueOnce(nonInviteQueryResult as unknown as ReturnType<typeof useQuery>)
mockQueryResults(
loggedInQueryResult as unknown as ReturnType<typeof useQuery>,
nonInviteQueryResult as unknown as ReturnType<typeof useQuery>,
)
render(<NormalForm />)
@ -134,9 +147,60 @@ describe('NormalForm', () => {
describe('Invite Redirects', () => {
it('should send logged-in invite visitors to the invite confirmation page', async () => {
mockUseSearchParams.mockReturnValue(new URLSearchParams('invite_token=invite-token'))
mockUseQuery
.mockReturnValueOnce(loggedInQueryResult as unknown as ReturnType<typeof useQuery>)
.mockReturnValueOnce(invitationQueryResult as unknown as ReturnType<typeof useQuery>)
mockQueryResults(
loggedInQueryResult as unknown as ReturnType<typeof useQuery>,
invitationQueryResult as unknown as ReturnType<typeof useQuery>,
)
render(<NormalForm />)
await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith(
'/signin/invite-settings?invite_token=invite-token',
)
})
})
it('should keep a different logged-in account on the invitation sign-in form', () => {
mockUseSearchParams.mockReturnValue(new URLSearchParams('invite_token=invite-token'))
mockQueryResults(
{
...loggedInQueryResult,
data: {
profile: {
id: 'account-id',
email: 'current@example.com',
},
},
} as unknown as ReturnType<typeof useQuery>,
invitationQueryResult as unknown as ReturnType<typeof useQuery>,
)
const queryClient = new QueryClient()
render(
<QueryClientProvider client={queryClient}>
<NormalForm />
</QueryClientProvider>,
)
expect(screen.getByRole('button', { name: 'login.signBtn' })).toBeInTheDocument()
expect(mockReplace).not.toHaveBeenCalled()
})
it('should match the logged-in account email case-insensitively', async () => {
mockUseSearchParams.mockReturnValue(new URLSearchParams('invite_token=invite-token'))
mockQueryResults(
{
...loggedInQueryResult,
data: {
profile: {
id: 'account-id',
email: 'Invitee@Example.com',
},
},
} as unknown as ReturnType<typeof useQuery>,
invitationQueryResult as unknown as ReturnType<typeof useQuery>,
)
render(<NormalForm />)
@ -153,7 +217,10 @@ describe('NormalForm', () => {
mockUseSearchParams.mockReturnValue(
new URLSearchParams('redirect_url=%2Fapps%3Ftag%3Dworkflow&source=pricing'),
)
mockUseQuery.mockReturnValue(nonInviteQueryResult as unknown as ReturnType<typeof useQuery>)
mockQueryResults(
nonInviteQueryResult as unknown as ReturnType<typeof useQuery>,
nonInviteQueryResult as unknown as ReturnType<typeof useQuery>,
)
mockUseSuspenseQuery.mockReturnValue({
data: {
enable_social_oauth_login: false,

View File

@ -1,4 +1,5 @@
import type { MockedFunction } from 'vitest'
import { useQuery } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useLocale } from '@/context/i18n'
@ -13,6 +14,7 @@ vi.mock('@tanstack/react-query', async () => {
await vi.importActual<typeof import('@tanstack/react-query')>('@tanstack/react-query')
return {
...actual,
useQuery: vi.fn(),
useQueryClient: vi.fn(() => ({
resetQueries: vi.fn(),
})),
@ -65,6 +67,7 @@ const mockUseLocale = useLocale as unknown as MockedFunction<typeof useLocale>
const mockUseRouter = useRouter as unknown as MockedFunction<typeof useRouter>
const mockUseSearchParams = useSearchParams as unknown as MockedFunction<typeof useSearchParams>
const mockActivateMember = activateMember as unknown as MockedFunction<typeof activateMember>
const mockUseQuery = vi.mocked(useQuery)
const mockUseInvitationCheck = useInvitationCheck as unknown as MockedFunction<
typeof useInvitationCheck
>
@ -96,6 +99,16 @@ describe('InviteSettingsPage', () => {
},
refetch: mockRefetch,
} as unknown as ReturnType<typeof useInvitationCheck>)
mockUseQuery.mockReturnValue({
data: {
profile: {
id: 'account-id',
email: 'invitee@example.com',
},
},
isPending: false,
error: null,
} as unknown as ReturnType<typeof useQuery>)
mockGetBrowserTimezone.mockReturnValue('Asia/Shanghai')
mockActivateMember.mockResolvedValue({ result: 'success' })
})
@ -260,4 +273,45 @@ describe('InviteSettingsPage', () => {
})
})
})
describe('Invitation account guard', () => {
it('should redirect a different logged-in account back to the invitation sign-in form', async () => {
mockUseQuery.mockReturnValue({
data: {
profile: {
id: 'current-account-id',
email: 'current@example.com',
},
},
isPending: false,
error: null,
} as unknown as ReturnType<typeof useQuery>)
render(<InviteSettingsPage />)
await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith('/signin?invite_token=invite-token')
})
expect(screen.queryByRole('button', { name: 'login.join Acme' })).not.toBeInTheDocument()
expect(mockActivateMember).not.toHaveBeenCalled()
})
it('should allow case-insensitive email matches', () => {
mockUseQuery.mockReturnValue({
data: {
profile: {
id: 'account-id',
email: 'Invitee@Example.com',
},
},
isPending: false,
error: null,
} as unknown as ReturnType<typeof useQuery>)
render(<InviteSettingsPage />)
expect(screen.getByRole('button', { name: 'login.join Acme' })).toBeInTheDocument()
expect(mockReplace).not.toHaveBeenCalled()
})
})
})

View File

@ -12,13 +12,14 @@ import {
} from '@langgenius/dify-ui/select'
import { toast } from '@langgenius/dify-ui/toast'
import { RiAccountCircleLine } from '@remixicon/react'
import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
import { useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
import { noop } from 'es-toolkit/function'
import { useCallback, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import { LICENSE_LINK } from '@/constants/link'
import { useLocale } from '@/context/i18n'
import { isLegacyBase401, userProfileQueryOptions } from '@/features/account-profile/client'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { i18n, setLocaleOnClient } from '@/i18n-config'
import { languages } from '@/i18n-config/language'
@ -30,6 +31,7 @@ import { useInvitationCheck } from '@/service/use-common'
import { replaceLoginRedirect } from '@/utils/login-redirect.client'
import { getBrowserTimezone, timezones } from '@/utils/timezone'
import { basePath } from '@/utils/var'
import { isInvitationForAccount } from '../utils/invitation-account'
import { resolvePostLoginRedirect } from '../utils/post-login-redirect'
type LanguageSelectOption = {
@ -67,6 +69,15 @@ export default function InviteSettingsPage() {
const queryClient = useQueryClient()
const searchParams = useSearchParams()
const token = decodeURIComponent(searchParams.get('invite_token') as string)
const {
data: userResp,
isPending: isProfilePending,
error: profileError,
} = useQuery({
...userProfileQueryOptions(),
throwOnError: (err) => !isLegacyBase401(err),
refetchOnWindowFocus: false,
})
const locale = useLocale()
const [name, setName] = useState('')
const [isActivating, setIsActivating] = useState(false)
@ -92,11 +103,28 @@ export default function InviteSettingsPage() {
},
}
const { data: checkRes, refetch: recheck } = useInvitationCheck(checkParams.params, !!token)
const isInvitationForCurrentAccount = isInvitationForAccount(
checkRes?.data?.email,
userResp?.profile.email,
)
const shouldReturnToSignIn =
!isProfilePending &&
Boolean(
checkRes?.is_valid &&
(isLegacyBase401(profileError) || (userResp && !isInvitationForCurrentAccount)),
)
const requiresAccountSetup =
checkRes?.data?.requires_setup ?? checkRes?.data?.account_status === 'pending'
useEffect(() => {
if (!shouldReturnToSignIn) return
router.replace(`/signin?${searchParams.toString()}`)
}, [router, searchParams, shouldReturnToSignIn])
const handleActivate = useCallback(async () => {
try {
if (!isInvitationForCurrentAccount) return
if (requiresAccountSetup && !name) {
toast.error(t(($) => $.enterYourName, { ns: 'login' }))
return
@ -127,7 +155,7 @@ export default function InviteSettingsPage() {
setIsActivating(false)
}
}, [
isActivating,
isInvitationForCurrentAccount,
language,
name,
queryClient,
@ -140,7 +168,7 @@ export default function InviteSettingsPage() {
t,
])
if (!checkRes) return <Loading />
if (isProfilePending || shouldReturnToSignIn || !checkRes) return <Loading />
if (!checkRes.is_valid) {
return (
<div className="flex flex-col md:w-[400px]">

View File

@ -19,6 +19,7 @@ import MailAndPasswordAuth from './components/mail-and-password-auth'
import SocialAuth from './components/social-auth'
import SSOAuth from './components/sso-auth'
import Split from './split'
import { isInvitationForAccount } from './utils/invitation-account'
import { resolvePostLoginRedirect } from './utils/post-login-redirect'
type AuthType = 'code' | 'password'
@ -67,6 +68,10 @@ function NormalForm() {
})
const workspaceName = invitationCheckResp?.data?.workspace_name || ''
const isInvitationForCurrentAccount = isInvitationForAccount(
invitationCheckResp?.data?.email,
userResp?.profile.email,
)
const hasSocialLogin = systemFeatures.enable_social_oauth_login
const hasSsoLogin = Boolean(systemFeatures.sso_enforced_for_signin)
const hasEmailCodeLogin = systemFeatures.enable_email_code_login
@ -83,18 +88,21 @@ function NormalForm() {
const noLoginMethodsConfigured =
!hasSocialLogin && !hasEmailCodeLogin && !hasEmailPasswordLogin && !hasSsoLogin
const allMethodsAreDisabled = noLoginMethodsConfigured || isInviteCheckError
const isLoading = isCheckLoading || isLoggedIn || (isInviteLink && isInviteCheckLoading)
const shouldRedirectLoggedInUser = isLoggedIn && (!isInviteLink || isInvitationForCurrentAccount)
const isLoading =
isCheckLoading || shouldRedirectLoggedInUser || (isInviteLink && isInviteCheckLoading)
useEffect(() => {
if (!isLoggedIn) return
if (isInviteLink) {
if (!isInvitationForCurrentAccount) return
router.replace(`/signin/invite-settings?${searchParams.toString()}`)
return
}
replaceLoginRedirect(resolvePostLoginRedirect(searchParams), router.replace, basePath)
}, [isInviteLink, isLoggedIn, router, searchParams])
}, [isInvitationForCurrentAccount, isInviteLink, isLoggedIn, router, searchParams])
useEffect(() => {
if (message) toast.error(message)

View File

@ -0,0 +1,9 @@
export const isInvitationForAccount = (
invitationEmail?: string | null,
accountEmail?: string | null,
) =>
Boolean(
invitationEmail &&
accountEmail &&
invitationEmail.trim().toLowerCase() === accountEmail.trim().toLowerCase(),
)