From 2b8e40af324006aaf57faafc3f61cb61c063ef3d Mon Sep 17 00:00:00 2001 From: zyssyz123 <916125788@qq.com> Date: Thu, 23 Jul 2026 11:56:32 +0800 Subject: [PATCH] fix(auth): validate account before accepting invitations (#39438) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/controllers/console/auth/activate.py | 14 +++ api/controllers/console/auth/error.py | 6 ++ api/openapi/markdown/console-openapi.md | 5 ++ .../console/auth/test_account_activation.py | 45 ++++++++++ .../api/console/activate/orpc.gen.ts | 9 +- web/app/signin/__tests__/normal-form.spec.tsx | 87 ++++++++++++++++--- .../invite-settings/__tests__/page.spec.tsx | 54 ++++++++++++ web/app/signin/invite-settings/page.tsx | 36 +++++++- web/app/signin/normal-form.tsx | 12 ++- web/app/signin/utils/invitation-account.ts | 9 ++ 10 files changed, 260 insertions(+), 17 deletions(-) create mode 100644 web/app/signin/utils/invitation-account.ts diff --git a/api/controllers/console/auth/activate.py b/api/controllers/console/auth/activate.py index 1f58dbe910f..3e9160f2bb0 100644 --- a/api/controllers/console/auth/activate.py +++ b/api/controllers/console/auth/activate.py @@ -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() diff --git a/api/controllers/console/auth/error.py b/api/controllers/console/auth/error.py index 81f1c6e70fa..562de31270f 100644 --- a/api/controllers/console/auth/error.py +++ b/api/controllers/console/auth/error.py @@ -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." diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 22496868c8c..15562e91955 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -260,7 +260,12 @@ Get account avatar url | 200 | Success | **application/json**: [AccountResponse](#accountresponse)
| ### [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 diff --git a/api/tests/unit_tests/controllers/console/auth/test_account_activation.py b/api/tests/unit_tests/controllers/console/auth/test_account_activation.py index 001ca0bf8fb..7669eed8d2a 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_account_activation.py +++ b/api/tests/unit_tests/controllers/console/auth/test_account_activation.py @@ -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") diff --git a/packages/contracts/generated/api/console/activate/orpc.gen.ts b/packages/contracts/generated/api/console/activate/orpc.gen.ts index 5ab609f5bb9..3bb61c9e7f1 100644 --- a/packages/contracts/generated/api/console/activate/orpc.gen.ts +++ b/packages/contracts/generated/api/console/activate/orpc.gen.ts @@ -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 })) diff --git a/web/app/signin/__tests__/normal-form.spec.tsx b/web/app/signin/__tests__/normal-form.spec.tsx index a5e39601b29..5bbb2eb6529 100644 --- a/web/app/signin/__tests__/normal-form.spec.tsx +++ b/web/app/signin/__tests__/normal-form.spec.tsx @@ -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, + inviteResult: ReturnType, +) => { + mockUseQuery.mockImplementation((options) => { + const queryKey = options.queryKey as readonly unknown[] + return (queryKey[0] === 'account' ? profileResult : inviteResult) as ReturnType + }) +} + 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) - .mockReturnValueOnce(nonInviteQueryResult as unknown as ReturnType) + mockQueryResults( + loggedInQueryResult as unknown as ReturnType, + nonInviteQueryResult as unknown as ReturnType, + ) render() @@ -119,9 +131,10 @@ describe('NormalForm', () => { mockUseSearchParams.mockReturnValue( new URLSearchParams('redirect_url=https%3A%2F%2Fgoogle.com'), ) - mockUseQuery - .mockReturnValueOnce(loggedInQueryResult as unknown as ReturnType) - .mockReturnValueOnce(nonInviteQueryResult as unknown as ReturnType) + mockQueryResults( + loggedInQueryResult as unknown as ReturnType, + nonInviteQueryResult as unknown as ReturnType, + ) render() @@ -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) - .mockReturnValueOnce(invitationQueryResult as unknown as ReturnType) + mockQueryResults( + loggedInQueryResult as unknown as ReturnType, + invitationQueryResult as unknown as ReturnType, + ) + + render() + + 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, + invitationQueryResult as unknown as ReturnType, + ) + + const queryClient = new QueryClient() + render( + + + , + ) + + 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, + invitationQueryResult as unknown as ReturnType, + ) render() @@ -153,7 +217,10 @@ describe('NormalForm', () => { mockUseSearchParams.mockReturnValue( new URLSearchParams('redirect_url=%2Fapps%3Ftag%3Dworkflow&source=pricing'), ) - mockUseQuery.mockReturnValue(nonInviteQueryResult as unknown as ReturnType) + mockQueryResults( + nonInviteQueryResult as unknown as ReturnType, + nonInviteQueryResult as unknown as ReturnType, + ) mockUseSuspenseQuery.mockReturnValue({ data: { enable_social_oauth_login: false, diff --git a/web/app/signin/invite-settings/__tests__/page.spec.tsx b/web/app/signin/invite-settings/__tests__/page.spec.tsx index f76974a11fa..907f95893a5 100644 --- a/web/app/signin/invite-settings/__tests__/page.spec.tsx +++ b/web/app/signin/invite-settings/__tests__/page.spec.tsx @@ -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('@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 const mockUseRouter = useRouter as unknown as MockedFunction const mockUseSearchParams = useSearchParams as unknown as MockedFunction const mockActivateMember = activateMember as unknown as MockedFunction +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) + mockUseQuery.mockReturnValue({ + data: { + profile: { + id: 'account-id', + email: 'invitee@example.com', + }, + }, + isPending: false, + error: null, + } as unknown as ReturnType) 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) + + render() + + 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) + + render() + + expect(screen.getByRole('button', { name: 'login.join Acme' })).toBeInTheDocument() + expect(mockReplace).not.toHaveBeenCalled() + }) + }) }) diff --git a/web/app/signin/invite-settings/page.tsx b/web/app/signin/invite-settings/page.tsx index 41de03fa7b4..33eeea9e6c2 100644 --- a/web/app/signin/invite-settings/page.tsx +++ b/web/app/signin/invite-settings/page.tsx @@ -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 + if (isProfilePending || shouldReturnToSignIn || !checkRes) return if (!checkRes.is_valid) { return (
diff --git a/web/app/signin/normal-form.tsx b/web/app/signin/normal-form.tsx index 008f781d7b8..6604d1d8786 100644 --- a/web/app/signin/normal-form.tsx +++ b/web/app/signin/normal-form.tsx @@ -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) diff --git a/web/app/signin/utils/invitation-account.ts b/web/app/signin/utils/invitation-account.ts new file mode 100644 index 00000000000..a5ae0a0ff95 --- /dev/null +++ b/web/app/signin/utils/invitation-account.ts @@ -0,0 +1,9 @@ +export const isInvitationForAccount = ( + invitationEmail?: string | null, + accountEmail?: string | null, +) => + Boolean( + invitationEmail && + accountEmail && + invitationEmail.trim().toLowerCase() === accountEmail.trim().toLowerCase(), + )