From 6b0c643ca60fd6cb242da1053af4a5b6b4e60425 Mon Sep 17 00:00:00 2001 From: zyssyz123 <916125788@qq.com> Date: Wed, 12 Aug 2026 11:08:19 +0800 Subject: [PATCH] feat: add trunstile (#40494) Co-authored-by: Joel Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> (cherry picked from commit 059c9ce0b69878b2bbb1cfc62760a7652621b90b) --- api/.env.example | 5 + api/configs/extra/__init__.py | 2 + api/configs/extra/turnstile_config.py | 34 +++ api/controllers/console/auth/error.py | 12 + api/controllers/console/auth/login.py | 31 ++- api/openapi/markdown/console-openapi.md | 10 +- api/services/turnstile_service.py | 86 +++++++ .../unit_tests/configs/test_dify_config.py | 13 + .../console/auth/test_email_verification.py | 145 ++++++++++- .../services/test_turnstile_service.py | 122 ++++++++++ docker/envs/core-services/api.env.example | 4 + oxlint-suppressions.json | 13 +- .../api/console/email-code-login/types.gen.ts | 5 +- .../api/console/email-code-login/zod.gen.ts | 7 +- web/.env.example | 3 + web/__tests__/env.spec.ts | 27 +++ web/__tests__/proxy-frame-options.spec.ts | 40 +++- .../signin/__tests__/countdown.spec.tsx | 22 ++ web/app/components/signin/countdown.tsx | 27 ++- .../signin/check-code/__tests__/page.spec.tsx | 192 ++++++++++++++- web/app/signin/check-code/page.tsx | 59 ++++- .../__tests__/mail-and-code-auth.spec.tsx | 226 ++++++++++++++++++ .../components/__tests__/turnstile.spec.tsx | 190 +++++++++++++++ .../signin/components/mail-and-code-auth.tsx | 39 ++- web/app/signin/components/turnstile.tsx | 159 ++++++++++++ web/config/index.ts | 1 + web/env.ts | 7 + web/i18n/ar-TN/login.json | 1 + web/i18n/de-DE/login.json | 1 + web/i18n/en-US/login.json | 1 + web/i18n/es-ES/login.json | 1 + web/i18n/fa-IR/login.json | 1 + web/i18n/fr-FR/login.json | 1 + web/i18n/hi-IN/login.json | 1 + web/i18n/id-ID/login.json | 1 + web/i18n/it-IT/login.json | 1 + web/i18n/ja-JP/login.json | 1 + web/i18n/ko-KR/login.json | 1 + web/i18n/nl-NL/login.json | 1 + web/i18n/pl-PL/login.json | 1 + web/i18n/pt-BR/login.json | 1 + web/i18n/ro-RO/login.json | 1 + web/i18n/ru-RU/login.json | 1 + web/i18n/sl-SI/login.json | 1 + web/i18n/th-TH/login.json | 1 + web/i18n/tr-TR/login.json | 1 + web/i18n/uk-UA/login.json | 1 + web/i18n/vi-VN/login.json | 1 + web/i18n/zh-Hans/login.json | 1 + web/i18n/zh-Hant/login.json | 1 + web/proxy.ts | 5 +- web/service/common.spec.ts | 42 ++++ web/service/common.ts | 9 +- 53 files changed, 1510 insertions(+), 50 deletions(-) create mode 100644 api/configs/extra/turnstile_config.py create mode 100644 api/services/turnstile_service.py create mode 100644 api/tests/unit_tests/services/test_turnstile_service.py create mode 100644 web/app/signin/components/__tests__/mail-and-code-auth.spec.tsx create mode 100644 web/app/signin/components/__tests__/turnstile.spec.tsx create mode 100644 web/app/signin/components/turnstile.tsx create mode 100644 web/service/common.spec.ts diff --git a/api/.env.example b/api/.env.example index 8a10f7452b5..1417f03840d 100644 --- a/api/.env.example +++ b/api/.env.example @@ -470,6 +470,11 @@ SENDGRID_API_KEY= # Sentry configuration SENTRY_DSN= +# Cloudflare Turnstile server-side verification for Dify Cloud sign-in +TURNSTILE_SECRET_KEY= +# Comma-separated parent or exact hostnames, for example: dify.ai,staging.dify.dev +TURNSTILE_ALLOWED_HOSTNAMES= + # DEBUG DEBUG=false ENABLE_REQUEST_LOGGING=False diff --git a/api/configs/extra/__init__.py b/api/configs/extra/__init__.py index 3987f326f4b..a142dbd7988 100644 --- a/api/configs/extra/__init__.py +++ b/api/configs/extra/__init__.py @@ -3,6 +3,7 @@ from configs.extra.archive_config import ArchiveStorageConfig from configs.extra.knowledge_fs_config import KnowledgeFSConfig from configs.extra.notion_config import NotionConfig from configs.extra.sentry_config import SentryConfig +from configs.extra.turnstile_config import TurnstileConfig class ExtraServiceConfig( @@ -12,5 +13,6 @@ class ExtraServiceConfig( KnowledgeFSConfig, NotionConfig, SentryConfig, + TurnstileConfig, ): pass diff --git a/api/configs/extra/turnstile_config.py b/api/configs/extra/turnstile_config.py new file mode 100644 index 00000000000..c4ae92924e3 --- /dev/null +++ b/api/configs/extra/turnstile_config.py @@ -0,0 +1,34 @@ +from pydantic import Field, SecretStr, field_validator +from pydantic_settings import BaseSettings + + +class TurnstileConfig(BaseSettings): + """Server-side Cloudflare Turnstile settings for Cloud sign-in.""" + + TURNSTILE_SECRET_KEY: SecretStr | None = Field( + default=None, + description="Secret key used to validate Cloudflare Turnstile tokens.", + ) + TURNSTILE_ALLOWED_HOSTNAMES: str = Field( + default="", + description="Comma-separated parent or exact hostnames accepted from Turnstile.", + ) + + @field_validator("TURNSTILE_SECRET_KEY", mode="before") + @classmethod + def normalize_secret_key(cls, value: object) -> object: + if isinstance(value, SecretStr): + normalized = value.get_secret_value().strip() + return SecretStr(normalized) if normalized else None + if isinstance(value, str): + normalized = value.strip() + return normalized or None + return value + + @property + def TURNSTILE_ALLOWED_HOSTNAME_SET(self) -> frozenset[str]: + return frozenset( + hostname.strip().lower().strip(".") + for hostname in self.TURNSTILE_ALLOWED_HOSTNAMES.split(",") + if hostname.strip().strip(".") + ) diff --git a/api/controllers/console/auth/error.py b/api/controllers/console/auth/error.py index 562de31270f..36bc34cc406 100644 --- a/api/controllers/console/auth/error.py +++ b/api/controllers/console/auth/error.py @@ -95,6 +95,18 @@ class EmailPasswordLoginLimitError(BaseHTTPException): code = 429 +class TurnstileVerificationFailedError(BaseHTTPException): + error_code = "turnstile_verification_failed" + description = "Turnstile verification failed. Please try again." + code = 400 + + +class TurnstileServiceUnavailableError(BaseHTTPException): + error_code = "turnstile_service_unavailable" + description = "Turnstile verification is temporarily unavailable. Please try again later." + code = 503 + + class EmailCodeLoginRateLimitExceededError(BaseHTTPException): error_code = "email_code_login_rate_limit_exceeded" description = "Too many login emails have been sent. Please try again in {minutes} minutes." diff --git a/api/controllers/console/auth/login.py b/api/controllers/console/auth/login.py index 49b248a1e48..7c1984e61c5 100644 --- a/api/controllers/console/auth/login.py +++ b/api/controllers/console/auth/login.py @@ -25,6 +25,8 @@ from controllers.console.auth.error import ( EmailPasswordLoginLimitError, InvalidEmailError, InvalidTokenError, + TurnstileServiceUnavailableError, + TurnstileVerificationFailedError, ) from controllers.console.error import ( AccountBannedError, @@ -42,6 +44,7 @@ from controllers.console.wraps import ( setup_required, with_current_user, ) +from enums.deployment_edition import DeploymentEdition from extensions.ext_database import db from libs.helper import EmailStr, extract_remote_ip from libs.helper import timezone as validate_timezone_string @@ -66,6 +69,11 @@ from services.errors.account import ( ) from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError from services.feature_service import FeatureService +from services.turnstile_service import ( + TurnstileChallengeRejectedError, + TurnstileService, + TurnstileUpstreamError, +) logger = logging.getLogger(__name__) @@ -80,6 +88,13 @@ class EmailPayload(BaseModel): language: str | None = Field(default=None) +class EmailCodeSendPayload(EmailPayload): + turnstile_token: str | None = Field( + default=None, + description="Cloudflare Turnstile token. Required at runtime for Dify Cloud.", + ) + + class EmailCodeLoginPayload(BaseModel): email: EmailStr = Field(...) code: str = Field(...) @@ -95,7 +110,7 @@ class EmailCodeLoginPayload(BaseModel): return validate_timezone_string(value) -register_schema_models(console_ns, LoginPayload, EmailPayload, EmailCodeLoginPayload) +register_schema_models(console_ns, LoginPayload, EmailPayload, EmailCodeSendPayload, EmailCodeLoginPayload) register_response_schema_models( console_ns, SimpleResultDataResponse, @@ -241,16 +256,26 @@ class ResetPasswordSendEmailApi(Resource): @console_ns.route("/email-code-login") class EmailCodeLoginSendEmailApi(Resource): @setup_required - @console_ns.expect(console_ns.models[EmailPayload.__name__]) + @console_ns.expect(console_ns.models[EmailCodeSendPayload.__name__]) @console_ns.response(200, "Success", console_ns.models[SimpleResultDataResponse.__name__]) def post(self): - args = EmailPayload.model_validate(console_ns.payload) + args = EmailCodeSendPayload.model_validate(console_ns.payload) normalized_email = args.email.lower() ip_address = extract_remote_ip(request) if AccountService.is_email_send_ip_limit(ip_address): raise EmailSendIpLimitError() + if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD: + try: + TurnstileService.verify(token=args.turnstile_token, remote_ip=ip_address) + except TurnstileChallengeRejectedError as exc: + logger.info("Turnstile rejected an email-code login challenge") + raise TurnstileVerificationFailedError() from exc + except TurnstileUpstreamError as exc: + logger.warning("Turnstile verification is unavailable", exc_info=True) + raise TurnstileServiceUnavailableError() from exc + if args.language is not None and args.language == "zh-Hans": language = "zh-Hans" else: diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 73d28cc0a3a..8b925d69a6b 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -6787,7 +6787,7 @@ Check if dataset is in use | Required | Schema | | -------- | ------ | -| Yes | **application/json**: [EmailPayload](#emailpayload)
| +| Yes | **application/json**: [EmailCodeSendPayload](#emailcodesendpayload)
| #### Responses @@ -17662,6 +17662,14 @@ Portable DSL reference that could not be restored in the target workspace. | timezone | string | | No | | token | string | | Yes | +#### EmailCodeSendPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| email | string | | Yes | +| language | string | | No | +| turnstile_token | string | Cloudflare Turnstile token. Required at runtime for Dify Cloud. | No | + #### EmailPayload | Name | Type | Description | Required | diff --git a/api/services/turnstile_service.py b/api/services/turnstile_service.py new file mode 100644 index 00000000000..84634b2310a --- /dev/null +++ b/api/services/turnstile_service.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import httpx +from pydantic import BaseModel, Field, SecretStr, ValidationError + +from configs import dify_config +from core.helper.http_client_pooling import get_pooled_http_client + +_SITEVERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify" +_EXPECTED_ACTION = "signin_code" +_MAX_TOKEN_LENGTH = 2048 +_CLIENT_ERROR_CODES = frozenset( + { + "bad-request", + "invalid-input-response", + "missing-input-response", + "timeout-or-duplicate", + } +) + +_http_client = get_pooled_http_client( + "cloudflare:turnstile", + lambda: httpx.Client( + timeout=httpx.Timeout(5.0, connect=3.0), + limits=httpx.Limits(max_keepalive_connections=20, max_connections=50), + ), +) + + +class TurnstileChallengeRejectedError(Exception): + """The submitted challenge is missing, invalid, expired, or not valid for this site.""" + + +class TurnstileUpstreamError(Exception): + """Turnstile could not be called or returned an unusable response.""" + + +class _TurnstileResponse(BaseModel): + success: bool + hostname: str | None = None + action: str | None = None + error_codes: list[str] = Field(default_factory=list, alias="error-codes") + + +class TurnstileService: + @classmethod + def verify(cls, *, token: str | None, remote_ip: str | None) -> None: + normalized_token = token.strip() if token else "" + if not normalized_token or len(normalized_token) > _MAX_TOKEN_LENGTH: + raise TurnstileChallengeRejectedError + + secret_key = dify_config.TURNSTILE_SECRET_KEY + allowed_hostnames = dify_config.TURNSTILE_ALLOWED_HOSTNAME_SET + if not isinstance(secret_key, SecretStr) or not allowed_hostnames: + raise TurnstileUpstreamError("Turnstile is not configured") + + payload = { + "secret": secret_key.get_secret_value(), + "response": normalized_token, + } + if remote_ip: + payload["remoteip"] = remote_ip + + try: + response = _http_client.post(_SITEVERIFY_URL, data=payload) + response.raise_for_status() + result = _TurnstileResponse.model_validate(response.json()) + except (httpx.HTTPError, ValidationError, ValueError) as exc: + raise TurnstileUpstreamError("Turnstile verification request failed") from exc + + if not result.success: + error_codes = frozenset(result.error_codes) + if error_codes and error_codes.issubset(_CLIENT_ERROR_CODES): + raise TurnstileChallengeRejectedError + raise TurnstileUpstreamError("Turnstile returned a server-side verification error") + + if result.action != _EXPECTED_ACTION or not cls._is_allowed_hostname(result.hostname, allowed_hostnames): + raise TurnstileChallengeRejectedError + + @staticmethod + def _is_allowed_hostname(hostname: str | None, allowed_hostnames: frozenset[str]) -> bool: + normalized_hostname = hostname.lower().strip(".") if hostname else "" + return any( + normalized_hostname == allowed or normalized_hostname.endswith(f".{allowed}") + for allowed in allowed_hostnames + ) diff --git a/api/tests/unit_tests/configs/test_dify_config.py b/api/tests/unit_tests/configs/test_dify_config.py index 3588f6beef1..59b795df12f 100644 --- a/api/tests/unit_tests/configs/test_dify_config.py +++ b/api/tests/unit_tests/configs/test_dify_config.py @@ -3,6 +3,7 @@ import os import pytest from flask import Flask from packaging.version import Version +from pydantic import SecretStr from yarl import URL from configs.app_config import DifyConfig @@ -109,6 +110,18 @@ def test_new_user_default_plugin_ids_are_parsed_from_env(monkeypatch: pytest.Mon ] +def test_turnstile_config_is_parsed_from_env(monkeypatch: pytest.MonkeyPatch) -> None: + _set_basic_config_env(monkeypatch) + monkeypatch.setenv("TURNSTILE_SECRET_KEY", " test-secret ") + monkeypatch.setenv("TURNSTILE_ALLOWED_HOSTNAMES", "dify.dev, Login.Example.COM. ") + + config = DifyConfig(_env_file=None) + + assert isinstance(config.TURNSTILE_SECRET_KEY, SecretStr) + assert config.TURNSTILE_SECRET_KEY.get_secret_value() == "test-secret" + assert frozenset({"dify.dev", "login.example.com"}) == config.TURNSTILE_ALLOWED_HOSTNAME_SET + + def test_plugin_remote_install_port_rejects_host_port_spec(monkeypatch: pytest.MonkeyPatch) -> None: """A 'host:port' compose publish spec must produce an actionable error, not an opaque int_parsing traceback.""" _set_basic_config_env(monkeypatch) diff --git a/api/tests/unit_tests/controllers/console/auth/test_email_verification.py b/api/tests/unit_tests/controllers/console/auth/test_email_verification.py index eef39e8d208..b29575bdc11 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_email_verification.py +++ b/api/tests/unit_tests/controllers/console/auth/test_email_verification.py @@ -9,14 +9,26 @@ This module tests the email code login mechanism including: """ import base64 -from unittest.mock import ANY, MagicMock, patch +from unittest.mock import ANY, MagicMock, PropertyMock, patch import pytest from flask import Flask from pydantic import ValidationError -from controllers.console.auth.error import EmailCodeError, InvalidEmailError, InvalidTokenError -from controllers.console.auth.login import EmailCodeLoginApi, EmailCodeLoginPayload, EmailCodeLoginSendEmailApi +from controllers.console.auth.error import ( + EmailCodeError, + InvalidEmailError, + InvalidTokenError, + TurnstileServiceUnavailableError, + TurnstileVerificationFailedError, +) +from controllers.console.auth.login import ( + EmailCodeLoginApi, + EmailCodeLoginPayload, + EmailCodeLoginSendEmailApi, + EmailCodeSendPayload, + EmailPayload, +) from controllers.console.error import ( AccountInFreezeError, AccountNotFound, @@ -24,7 +36,9 @@ from controllers.console.error import ( NotAllowedCreateWorkspace, WorkspacesLimitExceeded, ) +from enums.deployment_edition import DeploymentEdition from services.errors.account import AccountRegisterError +from services.turnstile_service import TurnstileChallengeRejectedError, TurnstileUpstreamError def encode_code(code: str) -> str: @@ -44,6 +58,11 @@ def test_email_code_login_payload_rejects_invalid_timezone(): ) +def test_turnstile_token_is_scoped_to_email_code_send_payload(): + assert "turnstile_token" in EmailCodeSendPayload.model_fields + assert "turnstile_token" not in EmailPayload.model_fields + + class TestEmailCodeLoginSendEmailApi: """Test cases for sending email verification codes.""" @@ -153,7 +172,8 @@ class TestEmailCodeLoginSendEmailApi: @patch("controllers.console.wraps.db") @patch("controllers.console.auth.login.AccountService.is_email_send_ip_limit") - def test_send_email_code_ip_rate_limited(self, mock_is_ip_limit, mock_db, app: Flask): + @patch("controllers.console.auth.login.TurnstileService.verify") + def test_send_email_code_ip_rate_limited(self, mock_verify, mock_is_ip_limit, mock_db, app: Flask): """ Test email code sending blocked by IP rate limit. @@ -165,10 +185,121 @@ class TestEmailCodeLoginSendEmailApi: mock_is_ip_limit.return_value = True # Act & Assert - with app.test_request_context("/email-code-login", method="POST", json={"email": "test@example.com"}): - api = EmailCodeLoginSendEmailApi() + with ( + patch( + "configs.app_config.DifyConfig.DEPLOYMENT_EDITION", + new_callable=PropertyMock, + return_value=DeploymentEdition.CLOUD, + ), + app.test_request_context("/email-code-login", method="POST", json={"email": "test@example.com"}), + ): with pytest.raises(EmailSendIpLimitError): - api.post() + EmailCodeLoginSendEmailApi().post() + + mock_verify.assert_not_called() + + @patch("controllers.console.wraps.db") + @patch("controllers.console.auth.login.AccountService.is_email_send_ip_limit", return_value=False) + @patch("controllers.console.auth.login.AccountService.get_user_through_email") + @patch("controllers.console.auth.login.AccountService.send_email_code_login_email", return_value="token") + @patch("controllers.console.auth.login.TurnstileService.verify") + def test_cloud_send_verifies_turnstile_before_sending_email( + self, + mock_verify, + mock_send_email, + mock_get_user, + mock_is_ip_limit, + mock_db, + app: Flask, + mock_account, + ): + mock_get_user.return_value = mock_account + + with ( + patch( + "configs.app_config.DifyConfig.DEPLOYMENT_EDITION", + new_callable=PropertyMock, + return_value=DeploymentEdition.CLOUD, + ), + app.test_request_context( + "/email-code-login", + method="POST", + json={"email": "test@example.com", "turnstile_token": "verified-token"}, + headers={"CF-Connecting-IP": "203.0.113.8"}, + ), + ): + response = EmailCodeLoginSendEmailApi().post() + + assert response["result"] == "success" + mock_verify.assert_called_once_with(token="verified-token", remote_ip="203.0.113.8") + mock_send_email.assert_called_once() + + @pytest.mark.parametrize( + ("service_error", "http_error"), + [ + (TurnstileChallengeRejectedError(), TurnstileVerificationFailedError), + (TurnstileUpstreamError(), TurnstileServiceUnavailableError), + ], + ) + @patch("controllers.console.wraps.db") + @patch("controllers.console.auth.login.AccountService.is_email_send_ip_limit", return_value=False) + @patch("controllers.console.auth.login.AccountService.get_user_through_email") + def test_cloud_send_maps_turnstile_errors_without_looking_up_account( + self, + mock_get_user, + mock_is_ip_limit, + mock_db, + app: Flask, + service_error: Exception, + http_error: type[Exception], + ): + with ( + patch( + "configs.app_config.DifyConfig.DEPLOYMENT_EDITION", + new_callable=PropertyMock, + return_value=DeploymentEdition.CLOUD, + ), + patch("controllers.console.auth.login.TurnstileService.verify", side_effect=service_error), + app.test_request_context( + "/email-code-login", + method="POST", + json={"email": "test@example.com", "turnstile_token": "challenge-token"}, + ), + pytest.raises(http_error), + ): + EmailCodeLoginSendEmailApi().post() + + mock_get_user.assert_not_called() + + @patch("controllers.console.wraps.db") + @patch("controllers.console.auth.login.AccountService.is_email_send_ip_limit", return_value=False) + @patch("controllers.console.auth.login.AccountService.get_user_through_email") + @patch("controllers.console.auth.login.AccountService.send_email_code_login_email", return_value="token") + @patch("controllers.console.auth.login.TurnstileService.verify") + def test_self_hosted_send_does_not_call_turnstile( + self, + mock_verify, + mock_send_email, + mock_get_user, + mock_is_ip_limit, + mock_db, + app: Flask, + mock_account, + ): + mock_get_user.return_value = mock_account + + with ( + patch( + "configs.app_config.DifyConfig.DEPLOYMENT_EDITION", + new_callable=PropertyMock, + return_value=DeploymentEdition.COMMUNITY, + ), + app.test_request_context("/email-code-login", method="POST", json={"email": "test@example.com"}), + ): + response = EmailCodeLoginSendEmailApi().post() + + assert response["result"] == "success" + mock_verify.assert_not_called() @patch("controllers.console.wraps.db") @patch("controllers.console.auth.login.AccountService.is_email_send_ip_limit") diff --git a/api/tests/unit_tests/services/test_turnstile_service.py b/api/tests/unit_tests/services/test_turnstile_service.py new file mode 100644 index 00000000000..795b914034f --- /dev/null +++ b/api/tests/unit_tests/services/test_turnstile_service.py @@ -0,0 +1,122 @@ +from unittest.mock import MagicMock + +import httpx +import pytest +from pydantic import SecretStr + +from services.turnstile_service import ( + TurnstileChallengeRejectedError, + TurnstileService, + TurnstileUpstreamError, +) + + +@pytest.fixture(autouse=True) +def configure_turnstile(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("services.turnstile_service.dify_config.TURNSTILE_SECRET_KEY", SecretStr("test-secret")) + monkeypatch.setattr("services.turnstile_service.dify_config.TURNSTILE_ALLOWED_HOSTNAMES", "dify.dev") + + +def mock_response(monkeypatch: pytest.MonkeyPatch, *, status_code: int = 200, payload: object) -> MagicMock: + response = httpx.Response( + status_code, + json=payload, + request=httpx.Request("POST", "https://challenges.cloudflare.com/turnstile/v0/siteverify"), + ) + post = MagicMock(return_value=response) + monkeypatch.setattr("services.turnstile_service._http_client.post", post) + return post + + +def test_verify_accepts_subdomain_and_forwards_remote_ip(monkeypatch: pytest.MonkeyPatch) -> None: + post = mock_response( + monkeypatch, + payload={"success": True, "action": "signin_code", "hostname": "agent.dify.dev"}, + ) + + TurnstileService.verify(token="verified-token", remote_ip="203.0.113.8") + + post.assert_called_once_with( + "https://challenges.cloudflare.com/turnstile/v0/siteverify", + data={ + "secret": "test-secret", + "response": "verified-token", + "remoteip": "203.0.113.8", + }, + ) + + +@pytest.mark.parametrize("token", [None, "", " ", "x" * 2049]) +def test_verify_rejects_missing_or_oversized_token(monkeypatch: pytest.MonkeyPatch, token: str | None) -> None: + post = MagicMock() + monkeypatch.setattr("services.turnstile_service._http_client.post", post) + + with pytest.raises(TurnstileChallengeRejectedError): + TurnstileService.verify(token=token, remote_ip=None) + + post.assert_not_called() + + +@pytest.mark.parametrize( + "payload", + [ + {"success": False, "error-codes": ["invalid-input-response"]}, + {"success": False, "error-codes": ["timeout-or-duplicate"]}, + {"success": True, "action": "different_action", "hostname": "agent.dify.dev"}, + {"success": True, "action": "signin_code", "hostname": "attacker.example"}, + ], +) +def test_verify_rejects_invalid_challenge(monkeypatch: pytest.MonkeyPatch, payload: object) -> None: + mock_response(monkeypatch, payload=payload) + + with pytest.raises(TurnstileChallengeRejectedError): + TurnstileService.verify(token="invalid-token", remote_ip=None) + + +@pytest.mark.parametrize( + "payload", + [ + {"success": False, "error-codes": ["invalid-input-secret"]}, + {"success": False, "error-codes": ["internal-error"]}, + {"unexpected": "response"}, + ], +) +def test_verify_maps_server_side_failures_to_upstream_error(monkeypatch: pytest.MonkeyPatch, payload: object) -> None: + mock_response(monkeypatch, payload=payload) + + with pytest.raises(TurnstileUpstreamError): + TurnstileService.verify(token="verified-token", remote_ip=None) + + +def test_verify_maps_http_errors_to_upstream_error(monkeypatch: pytest.MonkeyPatch) -> None: + mock_response(monkeypatch, status_code=503, payload={"error": "unavailable"}) + + with pytest.raises(TurnstileUpstreamError): + TurnstileService.verify(token="verified-token", remote_ip=None) + + +def test_verify_maps_timeout_to_upstream_error(monkeypatch: pytest.MonkeyPatch) -> None: + request = httpx.Request("POST", "https://challenges.cloudflare.com/turnstile/v0/siteverify") + monkeypatch.setattr( + "services.turnstile_service._http_client.post", + MagicMock(side_effect=httpx.ReadTimeout("timed out", request=request)), + ) + + with pytest.raises(TurnstileUpstreamError): + TurnstileService.verify(token="verified-token", remote_ip=None) + + +@pytest.mark.parametrize( + ("secret", "allowed_hostnames"), + [(None, "dify.dev"), (SecretStr("test-secret"), "")], +) +def test_verify_fails_closed_when_cloud_configuration_is_missing( + monkeypatch: pytest.MonkeyPatch, + secret: SecretStr | None, + allowed_hostnames: str, +) -> None: + monkeypatch.setattr("services.turnstile_service.dify_config.TURNSTILE_SECRET_KEY", secret) + monkeypatch.setattr("services.turnstile_service.dify_config.TURNSTILE_ALLOWED_HOSTNAMES", allowed_hostnames) + + with pytest.raises(TurnstileUpstreamError): + TurnstileService.verify(token="verified-token", remote_ip=None) diff --git a/docker/envs/core-services/api.env.example b/docker/envs/core-services/api.env.example index 538c554070d..82962444c52 100644 --- a/docker/envs/core-services/api.env.example +++ b/docker/envs/core-services/api.env.example @@ -16,3 +16,7 @@ KNOWLEDGE_FS_BASE_URL= KNOWLEDGE_FS_JWT_SECRET= KNOWLEDGE_FS_SSE_READ_TIMEOUT_SECONDS=300 KNOWLEDGE_FS_TIMEOUT_SECONDS=10 + +# Cloudflare Turnstile server-side verification for Dify Cloud sign-in +TURNSTILE_SECRET_KEY= +TURNSTILE_ALLOWED_HOSTNAMES= diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 87eb09c5b33..1f743c859b3 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -5929,17 +5929,6 @@ "count": 1 } }, - "web/app/signin/check-code/page.tsx": { - "jsx_a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx_a11y/no-static-element-interactions": { - "count": 1 - }, - "no-restricted-imports": { - "count": 1 - } - }, "web/app/signin/layout.tsx": { "typescript/no-explicit-any": { "count": 1 @@ -6427,4 +6416,4 @@ "count": 2 } } -} \ No newline at end of file +} diff --git a/packages/contracts/generated/api/console/email-code-login/types.gen.ts b/packages/contracts/generated/api/console/email-code-login/types.gen.ts index a07f11dfd19..293fe010f7b 100644 --- a/packages/contracts/generated/api/console/email-code-login/types.gen.ts +++ b/packages/contracts/generated/api/console/email-code-login/types.gen.ts @@ -4,9 +4,10 @@ export type ClientOptions = { baseUrl: `${string}://${string}/console/api` | (string & {}) } -export type EmailPayload = { +export type EmailCodeSendPayload = { email: string language?: string | null + turnstile_token?: string | null } export type SimpleResultDataResponse = { @@ -27,7 +28,7 @@ export type SimpleResultResponse = { } export type PostEmailCodeLoginData = { - body: EmailPayload + body: EmailCodeSendPayload path?: never query?: never url: '/email-code-login' diff --git a/packages/contracts/generated/api/console/email-code-login/zod.gen.ts b/packages/contracts/generated/api/console/email-code-login/zod.gen.ts index af72ec33867..14c69a9076a 100644 --- a/packages/contracts/generated/api/console/email-code-login/zod.gen.ts +++ b/packages/contracts/generated/api/console/email-code-login/zod.gen.ts @@ -3,11 +3,12 @@ import * as z from 'zod' /** - * EmailPayload + * EmailCodeSendPayload */ -export const zEmailPayload = z.object({ +export const zEmailCodeSendPayload = z.object({ email: z.string(), language: z.string().nullish(), + turnstile_token: z.string().nullish(), }) /** @@ -36,7 +37,7 @@ export const zSimpleResultResponse = z.object({ result: z.string(), }) -export const zPostEmailCodeLoginBody = zEmailPayload +export const zPostEmailCodeLoginBody = zEmailCodeSendPayload /** * Success diff --git a/web/.env.example b/web/.env.example index 1c8ed103949..906d48433d8 100644 --- a/web/.env.example +++ b/web/.env.example @@ -105,6 +105,9 @@ NEXT_PUBLIC_AMPLITUDE_API_KEY= # CookieYes site key for the Dify Cloud consent banner NEXT_PUBLIC_COOKIEYES_SITE_KEY= +# Cloudflare Turnstile site key for Dify Cloud sign-in verification +NEXT_PUBLIC_TURNSTILE_SITE_KEY= + # The public origin of the console web application NEXT_PUBLIC_WEB_PREFIX= diff --git a/web/__tests__/env.spec.ts b/web/__tests__/env.spec.ts index e2dcc0c2943..35a9f9e2483 100644 --- a/web/__tests__/env.spec.ts +++ b/web/__tests__/env.spec.ts @@ -1,5 +1,6 @@ describe('env runtime transport', () => { const originalAgentV2Env = process.env.NEXT_PUBLIC_ENABLE_AGENT_V2 + const originalTurnstileSiteKey = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY beforeEach(() => { vi.clearAllMocks() @@ -7,12 +8,16 @@ describe('env runtime transport', () => { vi.doUnmock('../utils/client') document.body.removeAttribute('data-enable-agent-v2') document.body.removeAttribute('data-enable-agent-v-2') + document.body.removeAttribute('data-turnstile-site-key') delete process.env.NEXT_PUBLIC_ENABLE_AGENT_V2 + delete process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY }) afterAll(() => { if (originalAgentV2Env === undefined) delete process.env.NEXT_PUBLIC_ENABLE_AGENT_V2 else process.env.NEXT_PUBLIC_ENABLE_AGENT_V2 = originalAgentV2Env + if (originalTurnstileSiteKey === undefined) delete process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY + else process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY = originalTurnstileSiteKey }) it('should read NEXT_PUBLIC_ENABLE_AGENT_V2 from the browser runtime dataset key', async () => { @@ -37,4 +42,26 @@ describe('env runtime transport', () => { expect(datasetMap['data-enable-agent-v2']).toBe(true) expect(datasetMap['data-enable-agent-v-2']).toBeUndefined() }) + + it('should read the Turnstile site key from the browser runtime dataset', async () => { + document.body.setAttribute('data-turnstile-site-key', 'site-key-for-tests') + + const { env } = await import('../env') + + expect(env.NEXT_PUBLIC_TURNSTILE_SITE_KEY).toBe('site-key-for-tests') + }) + + it('should emit the Turnstile site key in the server runtime dataset', async () => { + process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY = 'site-key-for-tests' + + vi.doMock('../utils/client', () => ({ + isClient: false, + isServer: true, + })) + + const { getDatasetMap } = await import('../env') + const datasetMap = getDatasetMap() + + expect(datasetMap['data-turnstile-site-key']).toBe('site-key-for-tests') + }) }) diff --git a/web/__tests__/proxy-frame-options.spec.ts b/web/__tests__/proxy-frame-options.spec.ts index a2aafa1da20..e75cad31609 100644 --- a/web/__tests__/proxy-frame-options.spec.ts +++ b/web/__tests__/proxy-frame-options.spec.ts @@ -1,7 +1,32 @@ -import { describe, expect, it } from 'vitest' -import { canEmbedPath } from '@/proxy' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { canEmbedPath, proxy } from '@/proxy' + +const mockEnv = vi.hoisted(() => ({ + NEXT_PUBLIC_ALLOW_EMBED: false, + NEXT_PUBLIC_CSP_WHITELIST: 'https://example.com', + NEXT_PUBLIC_TURNSTILE_SITE_KEY: '', +})) + +vi.mock('@/env', () => ({ + env: mockEnv, +})) + +const createRequest = (url: string) => { + const nextUrl = new URL(url) as URL & { clone: () => URL } + nextUrl.clone = () => new URL(nextUrl) + + return { + headers: new Headers(), + nextUrl, + } as Parameters[0] +} describe('proxy frame options', () => { + afterEach(() => { + mockEnv.NEXT_PUBLIC_ALLOW_EMBED = false + mockEnv.NEXT_PUBLIC_TURNSTILE_SITE_KEY = '' + vi.unstubAllEnvs() + }) it('should allow embedded share routes', () => { expect(canEmbedPath('/chatbot/token')).toBe(true) expect(canEmbedPath('/workflow/token')).toBe(true) @@ -17,4 +42,15 @@ describe('proxy frame options', () => { expect(canEmbedPath('/agents/agent-1/access')).toBe(false) expect(canEmbedPath('/apps')).toBe(false) }) + + it('should allow Cloudflare Turnstile resources when its site key is configured', () => { + vi.stubEnv('NODE_ENV', 'production') + mockEnv.NEXT_PUBLIC_TURNSTILE_SITE_KEY = 'site-key-for-tests' + + const response = proxy(createRequest('https://cloud.dify.ai/signin')) + + expect(response.headers.get('content-security-policy')).toContain( + 'https://challenges.cloudflare.com', + ) + }) }) diff --git a/web/app/components/signin/__tests__/countdown.spec.tsx b/web/app/components/signin/__tests__/countdown.spec.tsx index c9bb772d5a2..ef8e3129e3b 100644 --- a/web/app/components/signin/__tests__/countdown.spec.tsx +++ b/web/app/components/signin/__tests__/countdown.spec.tsx @@ -50,4 +50,26 @@ describe('Countdown', () => { expect(localStorage.getItem(COUNT_DOWN_KEY)).toBe(String(COUNT_DOWN_TIME_MS)) expect(onResend).toHaveBeenCalledOnce() }) + + it('does not restart the countdown while resend is disabled', () => { + localStorage.setItem(COUNT_DOWN_KEY, '0') + const onResend = vi.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: 'login.checkCode.resend' })) + + expect(localStorage.getItem(COUNT_DOWN_KEY)).toBe('0') + expect(onResend).not.toHaveBeenCalled() + }) + + it('lets the caller defer restarting the countdown until resend succeeds', () => { + localStorage.setItem(COUNT_DOWN_KEY, '0') + const onResend = vi.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: 'login.checkCode.resend' })) + + expect(localStorage.getItem(COUNT_DOWN_KEY)).toBe('0') + expect(onResend).toHaveBeenCalledOnce() + }) }) diff --git a/web/app/components/signin/countdown.tsx b/web/app/components/signin/countdown.tsx index 322751178aa..003f6e54b9a 100644 --- a/web/app/components/signin/countdown.tsx +++ b/web/app/components/signin/countdown.tsx @@ -7,16 +7,26 @@ import { COUNT_DOWN_TIME_MS, useCountdownLeftTimeValue, useSetCountdownLeftTime type CountdownProps = { onResend?: () => void + resendDisabled?: boolean + restartOnResend?: boolean } -export default function Countdown({ onResend }: CountdownProps) { +export default function Countdown({ + onResend, + resendDisabled, + restartOnResend = true, +}: CountdownProps) { const isClient = useIsClient() if (!isClient) return return ( }> - + ) } @@ -31,7 +41,7 @@ function CountdownFallback() { ) } -function CountdownContent({ onResend }: CountdownProps) { +function CountdownContent({ onResend, resendDisabled, restartOnResend }: CountdownProps) { const { t } = useTranslation() const storedLeftTime = useCountdownLeftTimeValue() const setStoredLeftTime = useSetCountdownLeftTime() @@ -44,9 +54,11 @@ function CountdownContent({ onResend }: CountdownProps) { }, }) - const resend = async function () { - setLeftTime(COUNT_DOWN_TIME_MS) - setStoredLeftTime(`${COUNT_DOWN_TIME_MS}`) + const resend = function () { + if (restartOnResend) { + setLeftTime(COUNT_DOWN_TIME_MS) + setStoredLeftTime(`${COUNT_DOWN_TIME_MS}`) + } onResend?.() } @@ -61,7 +73,8 @@ function CountdownContent({ onResend }: CountdownProps) { {time <= 0 && ( + ), +})) + +vi.mock('@/next/script', () => ({ + default: (props: ScriptProps) => { + turnstileMocks.scriptProps = props + return null + }, })) vi.mock('@/next/navigation', () => ({ @@ -48,13 +92,27 @@ vi.mock('@/utils/timezone', () => ({ })) function createQueryClient() { - return new QueryClient({ + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, }, }, }) + queryClient.setQueryData(['system-features'], { + deployment_edition: turnstileMocks.deploymentEdition, + }) + return queryClient +} + +function installTurnstileApi() { + Object.defineProperty(window, 'turnstile', { + configurable: true, + value: { + remove: turnstileMocks.remove, + render: turnstileMocks.render, + }, + }) } const accountProfile: GetAccountProfileResponse = { @@ -80,6 +138,25 @@ describe('CheckCode', () => { redirect_url: '/apps', token: 'email-login-token', }) + turnstileMocks.deploymentEdition = 'COMMUNITY' + turnstileMocks.scriptProps = undefined + turnstileMocks.siteKey = '' + turnstileMocks.render.mockImplementation( + (container: HTMLElement, options: TurnstileOptions) => { + const widgetId = `widget-${turnstileMocks.render.mock.calls.length}` + const verifyButton = document.createElement('button') + verifyButton.type = 'button' + verifyButton.dataset.widgetId = widgetId + verifyButton.textContent = 'verify-turnstile' + verifyButton.addEventListener('click', () => options.callback('fresh-turnstile-token')) + container.appendChild(verifyButton) + return widgetId + }, + ) + turnstileMocks.remove.mockImplementation((widgetId: string) => { + document.querySelector(`[data-widget-id="${widgetId}"]`)?.remove() + }) + installTurnstileApi() vi.mocked(emailLoginWithCode).mockResolvedValue({ result: 'success' }) }) @@ -87,6 +164,115 @@ describe('CheckCode', () => { vi.unstubAllGlobals() }) + it('uses a fresh Turnstile token for each Cloud resend', async () => { + const user = userEvent.setup() + turnstileMocks.deploymentEdition = 'CLOUD' + turnstileMocks.siteKey = 'cloud-site-key' + const queryClient = createQueryClient() + vi.mocked(sendEMailLoginCode).mockResolvedValue({ result: 'success', data: 'new-login-token' }) + + render( + + + , + ) + + const resendButton = screen.getByRole('button', { name: 'resend-code' }) + expect(resendButton).toBeEnabled() + expect(screen.queryByRole('button', { name: 'verify-turnstile' })).not.toBeInTheDocument() + + await user.click(resendButton) + + expect(resendButton).toBeDisabled() + act(() => { + turnstileMocks.scriptProps?.onReady?.() + }) + await user.click(await screen.findByRole('button', { name: 'verify-turnstile' })) + + await waitFor(() => { + expect(sendEMailLoginCode).toHaveBeenCalledWith( + 'user@example.com', + expect.any(String), + 'fresh-turnstile-token', + ) + }) + await waitFor(() => { + expect(screen.queryByRole('button', { name: 'verify-turnstile' })).not.toBeInTheDocument() + }) + }) + + it('keeps Turnstile script-error recovery available during a Cloud resend', async () => { + const user = userEvent.setup() + turnstileMocks.deploymentEdition = 'CLOUD' + turnstileMocks.siteKey = 'cloud-site-key' + const queryClient = createQueryClient() + vi.mocked(sendEMailLoginCode).mockResolvedValue({ result: 'success', data: 'new-login-token' }) + Object.defineProperty(window, 'turnstile', { + configurable: true, + value: undefined, + }) + + render( + + + , + ) + + const resendButton = screen.getByRole('button', { name: 'resend-code' }) + await user.click(resendButton) + const initialScriptSrc = turnstileMocks.scriptProps?.src + + act(() => { + turnstileMocks.scriptProps?.onError?.() + }) + + expect(screen.getByRole('alert')).toHaveTextContent('login.turnstile.loadError') + expect(resendButton).toBeDisabled() + + await user.click(screen.getByRole('button', { name: 'common.operation.retry' })) + + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(turnstileMocks.scriptProps?.src).not.toBe(initialScriptSrc) + + installTurnstileApi() + act(() => { + turnstileMocks.scriptProps?.onReady?.() + }) + await user.click(await screen.findByRole('button', { name: 'verify-turnstile' })) + + await waitFor(() => { + expect(sendEMailLoginCode).toHaveBeenCalledWith( + 'user@example.com', + expect.any(String), + 'fresh-turnstile-token', + ) + }) + }) + + it('does not require Turnstile outside Cloud based on the site key alone', async () => { + const user = userEvent.setup() + const queryClient = createQueryClient() + turnstileMocks.siteKey = 'site-key-not-used-outside-cloud' + vi.mocked(sendEMailLoginCode).mockResolvedValue({ result: 'success', data: 'new-login-token' }) + + render( + + + , + ) + + expect(screen.queryByRole('button', { name: 'verify-turnstile' })).not.toBeInTheDocument() + const resendButton = screen.getByRole('button', { name: 'resend-code' }) + expect(resendButton).toBeEnabled() + await user.click(resendButton) + + expect(sendEMailLoginCode).toHaveBeenCalledWith( + 'user@example.com', + expect.any(String), + undefined, + ) + }) + describe('Post-login profile bootstrap', () => { it('should resolve an inactive profile query before navigating to the console home', async () => { const user = userEvent.setup() diff --git a/web/app/signin/check-code/page.tsx b/web/app/signin/check-code/page.tsx index 422bdb175a8..616f0e81e85 100644 --- a/web/app/signin/check-code/page.tsx +++ b/web/app/signin/check-code/page.tsx @@ -1,28 +1,33 @@ 'use client' import type { FormEvent } from 'react' import { Button } from '@langgenius/dify-ui/button' +import { Input } from '@langgenius/dify-ui/input' import { toast } from '@langgenius/dify-ui/toast' import { RiArrowLeftLine, RiMailSendFill } from '@remixicon/react' -import { useQueryClient } from '@tanstack/react-query' +import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query' import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { trackEvent } from '@/app/components/base/amplitude' -import Input from '@/app/components/base/input' import Countdown from '@/app/components/signin/countdown' +import { COUNT_DOWN_TIME_MS, useSetCountdownLeftTime } from '@/app/components/signin/storage' +import { TURNSTILE_SITE_KEY } from '@/config' import { useLocale } from '@/context/i18n' import { userProfileQueryOptions } from '@/features/account-profile/client' +import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { useRouter, useSearchParams } from '@/next/navigation' import { emailLoginWithCode, sendEMailLoginCode } from '@/service/common' import { encryptVerificationCode } from '@/utils/encryption' import { replaceLoginRedirect } from '@/utils/login-redirect.client' import { getBrowserTimezone } from '@/utils/timezone' import { basePath } from '@/utils/var' +import Turnstile from '../components/turnstile' import { resolvePostLoginRedirect } from '../utils/post-login-redirect' export default function CheckCode() { const { t, i18n } = useTranslation() const router = useRouter() const queryClient = useQueryClient() + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const searchParams = useSearchParams() const email = decodeURIComponent(searchParams.get('email') as string) const token = decodeURIComponent(searchParams.get('token') as string) @@ -30,8 +35,16 @@ export default function CheckCode() { const language = i18n.language const [code, setVerifyCode] = useState('') const [loading, setIsLoading] = useState(false) + const [isResending, setIsResending] = useState(false) + const [showResendTurnstile, setShowResendTurnstile] = useState(false) + const [countdownGeneration, setCountdownGeneration] = useState(0) const locale = useLocale() + const setCountdownLeftTime = useSetCountdownLeftTime() const codeInputRef = useRef(null) + const turnstileSiteKey = TURNSTILE_SITE_KEY.trim() + const isTurnstileRequired = systemFeatures.deployment_edition === 'CLOUD' + const shouldRenderResendTurnstile = + isTurnstileRequired && Boolean(turnstileSiteKey) && showResendTurnstile const verify = async () => { try { @@ -83,19 +96,37 @@ export default function CheckCode() { codeInputRef.current?.focus() }, []) - const resendCode = async () => { + const resendCode = async (turnstileToken?: string) => { + setIsResending(true) try { - const ret = await sendEMailLoginCode(email, locale) + const ret = await sendEMailLoginCode( + email, + locale, + isTurnstileRequired ? turnstileToken : undefined, + ) if (ret.result === 'success') { + setCountdownLeftTime(`${COUNT_DOWN_TIME_MS}`) + setCountdownGeneration((value) => value + 1) const params = new URLSearchParams(searchParams) params.set('token', encodeURIComponent(ret.data)) router.replace(`/signin/check-code?${params.toString()}`) } } catch (error) { console.error(error) + } finally { + setIsResending(false) + setShowResendTurnstile(false) } } + const handleResend = () => { + if (isTurnstileRequired) { + setShowResendTurnstile(true) + return + } + void resendCode() + } + return (
@@ -139,7 +170,25 @@ export default function CheckCode() { > {t(($) => $['checkCode.verify'], { ns: 'login' })} - + {shouldRenderResendTurnstile && ( + { + void resendCode(turnstileToken) + }} + onInvalidate={() => { + setShowResendTurnstile(false) + }} + /> + )} +
diff --git a/web/app/signin/components/__tests__/mail-and-code-auth.spec.tsx b/web/app/signin/components/__tests__/mail-and-code-auth.spec.tsx new file mode 100644 index 00000000000..232cb399f12 --- /dev/null +++ b/web/app/signin/components/__tests__/mail-and-code-auth.spec.tsx @@ -0,0 +1,226 @@ +import { act, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { renderWithConsoleQuery } from '@/test/console/query-data' +import MailAndCodeAuth from '../mail-and-code-auth' + +type TurnstileOptions = { + sitekey: string + action: string + callback: (token: string) => void + 'error-callback': (errorCode: string) => boolean + 'expired-callback': () => void + 'timeout-callback': () => void +} + +const mocks = vi.hoisted(() => ({ + push: vi.fn(), + remove: vi.fn(), + render: vi.fn(), + sendEMailLoginCode: vi.fn(), + setCountdownLeftTime: vi.fn(), + turnstileSiteKey: 'site-key-for-tests', +})) + +let turnstileOptions: TurnstileOptions | undefined + +const renderMailAndCodeAuth = (deploymentEdition: 'CLOUD' | 'COMMUNITY' = 'CLOUD') => + renderWithConsoleQuery(, { + systemFeatures: { deployment_edition: deploymentEdition }, + }) + +vi.mock('@/next/script', async () => { + const { useEffect } = await vi.importActual('react') + + function ScriptMock({ onReady }: { onReady?: () => void }) { + useEffect(() => { + onReady?.() + }, [onReady]) + return null + } + + return { + default: ScriptMock, + } +}) + +vi.mock('@/next/navigation', () => ({ + useRouter: () => ({ push: mocks.push }), + useSearchParams: () => new URLSearchParams(), +})) + +vi.mock('@/context/i18n', () => ({ + useLocale: () => 'en-US', +})) + +vi.mock('@/app/components/signin/storage', () => ({ + COUNT_DOWN_TIME_MS: 60_000, + useSetCountdownLeftTime: () => mocks.setCountdownLeftTime, +})) + +vi.mock('@/config', async () => { + const actual = await vi.importActual('@/config') + return { + ...actual, + get TURNSTILE_SITE_KEY() { + return mocks.turnstileSiteKey + }, + } +}) + +vi.mock('@/service/common', () => ({ + sendEMailLoginCode: (...args: unknown[]) => mocks.sendEMailLoginCode(...args), +})) + +describe('MailAndCodeAuth', () => { + beforeEach(() => { + vi.clearAllMocks() + turnstileOptions = undefined + mocks.turnstileSiteKey = 'site-key-for-tests' + mocks.render.mockImplementation((_container: HTMLElement, options: TurnstileOptions) => { + turnstileOptions = options + return 'widget-id' + }) + mocks.sendEMailLoginCode.mockResolvedValue({ result: 'success', data: 'login-token' }) + Object.defineProperty(window, 'turnstile', { + configurable: true, + value: { + remove: mocks.remove, + render: mocks.render, + }, + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('enables SaaS email-code login only while Turnstile verification is valid', async () => { + const user = userEvent.setup() + renderMailAndCodeAuth() + + await user.type(screen.getByRole('textbox', { name: 'login.email' }), 'user@example.com') + const continueButton = screen.getByRole('button', { name: 'login.signup.verifyMail' }) + + await waitFor(() => { + expect(mocks.render).toHaveBeenCalledTimes(1) + }) + expect(turnstileOptions).toMatchObject({ + sitekey: 'site-key-for-tests', + action: 'signin_code', + }) + expect(continueButton).toBeDisabled() + + act(() => { + turnstileOptions?.callback('turnstile-token') + }) + expect(continueButton).toBeEnabled() + + act(() => { + turnstileOptions?.['expired-callback']() + }) + expect(continueButton).toBeDisabled() + + act(() => { + turnstileOptions?.callback('fresh-turnstile-token') + turnstileOptions?.['error-callback']('network-error') + }) + expect(continueButton).toBeDisabled() + + act(() => { + turnstileOptions?.callback('another-turnstile-token') + turnstileOptions?.['timeout-callback']() + }) + expect(continueButton).toBeDisabled() + }) + + it('submits the SaaS email-code login after Turnstile verification succeeds', async () => { + const user = userEvent.setup() + renderMailAndCodeAuth() + + await user.type(screen.getByRole('textbox', { name: 'login.email' }), 'user@example.com') + await waitFor(() => { + expect(turnstileOptions).toBeDefined() + }) + act(() => { + turnstileOptions?.callback('turnstile-token') + }) + await user.click(screen.getByRole('button', { name: 'login.signup.verifyMail' })) + + await waitFor(() => { + expect(mocks.sendEMailLoginCode).toHaveBeenCalledWith( + 'user@example.com', + 'en-US', + 'turnstile-token', + ) + }) + expect(mocks.push).toHaveBeenCalledWith(expect.stringContaining('/signin/check-code?')) + }) + + it('requires a fresh Turnstile token after an email-code request fails', async () => { + const user = userEvent.setup() + vi.spyOn(console, 'error').mockImplementation(() => {}) + mocks.sendEMailLoginCode + .mockRejectedValueOnce(new Error('email send failed')) + .mockResolvedValueOnce({ result: 'success', data: 'login-token' }) + renderMailAndCodeAuth() + + await user.type(screen.getByRole('textbox', { name: 'login.email' }), 'user@example.com') + await waitFor(() => { + expect(turnstileOptions).toBeDefined() + }) + act(() => { + turnstileOptions?.callback('consumed-turnstile-token') + }) + const continueButton = screen.getByRole('button', { name: 'login.signup.verifyMail' }) + await user.click(continueButton) + + await waitFor(() => { + expect(mocks.sendEMailLoginCode).toHaveBeenCalledWith( + 'user@example.com', + 'en-US', + 'consumed-turnstile-token', + ) + }) + await waitFor(() => { + expect(continueButton).toBeDisabled() + expect(mocks.render).toHaveBeenCalledTimes(2) + }) + + act(() => { + turnstileOptions?.callback('fresh-turnstile-token') + }) + expect(continueButton).toBeEnabled() + await user.click(continueButton) + + await waitFor(() => { + expect(mocks.sendEMailLoginCode).toHaveBeenLastCalledWith( + 'user@example.com', + 'en-US', + 'fresh-turnstile-token', + ) + }) + expect(mocks.push).toHaveBeenCalledWith(expect.stringContaining('/signin/check-code?')) + }) + + it('keeps non-SaaS email-code login independent of Turnstile', async () => { + const user = userEvent.setup() + renderMailAndCodeAuth('COMMUNITY') + + await user.type(screen.getByRole('textbox', { name: 'login.email' }), 'user@example.com') + + expect(screen.getByRole('button', { name: 'login.signup.verifyMail' })).toBeEnabled() + expect(mocks.render).not.toHaveBeenCalled() + }) + + it('keeps SaaS email-code login disabled when the Turnstile site key is missing', async () => { + const user = userEvent.setup() + mocks.turnstileSiteKey = '' + renderMailAndCodeAuth() + + await user.type(screen.getByRole('textbox', { name: 'login.email' }), 'user@example.com') + + expect(screen.getByRole('button', { name: 'login.signup.verifyMail' })).toBeDisabled() + expect(mocks.render).not.toHaveBeenCalled() + }) +}) diff --git a/web/app/signin/components/__tests__/turnstile.spec.tsx b/web/app/signin/components/__tests__/turnstile.spec.tsx new file mode 100644 index 00000000000..f573dce15b3 --- /dev/null +++ b/web/app/signin/components/__tests__/turnstile.spec.tsx @@ -0,0 +1,190 @@ +import { act, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { StrictMode } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Turnstile from '../turnstile' + +type ScriptProps = { + id: string + src: string + onReady?: () => void + onError?: () => void +} + +type TurnstileOptions = { + callback: (token: string) => void + 'error-callback': (errorCode: string) => boolean + 'expired-callback': () => void + 'timeout-callback': () => void + 'unsupported-callback': () => void +} + +const mocks = vi.hoisted(() => ({ + remove: vi.fn(), + render: vi.fn(), + scriptIsCached: false, + scriptProps: undefined as ScriptProps | undefined, +})) + +let turnstileOptions: TurnstileOptions | undefined + +vi.mock('@/next/script', async () => { + const { useEffect, useRef } = await vi.importActual('react') + + function ScriptMock(props: ScriptProps) { + const { onReady } = props + const hasCalledOnReadyRef = useRef(false) + mocks.scriptProps = props + + useEffect(() => { + if (!mocks.scriptIsCached || hasCalledOnReadyRef.current) return + hasCalledOnReadyRef.current = true + onReady?.() + }, [onReady]) + + return null + } + + return { + default: ScriptMock, + } +}) + +describe('Turnstile', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.scriptIsCached = false + mocks.scriptProps = undefined + turnstileOptions = undefined + Object.defineProperty(window, 'turnstile', { + configurable: true, + value: undefined, + }) + }) + + it('keeps a cached-script widget mounted after Strict Mode replays effects', async () => { + const mountedWidgets = new Map() + mocks.scriptIsCached = true + mocks.render.mockImplementation((container: HTMLElement) => { + const widgetId = `widget-${mocks.render.mock.calls.length}` + const widget = document.createElement('div') + widget.setAttribute('role', 'region') + widget.setAttribute('aria-label', 'Turnstile challenge') + container.appendChild(widget) + mountedWidgets.set(widgetId, widget) + return widgetId + }) + mocks.remove.mockImplementation((widgetId: string) => { + mountedWidgets.get(widgetId)?.remove() + mountedWidgets.delete(widgetId) + }) + Object.defineProperty(window, 'turnstile', { + configurable: true, + value: { + remove: mocks.remove, + render: mocks.render, + }, + }) + + render( + + + , + ) + + expect(await screen.findByRole('region', { name: 'Turnstile challenge' })).toBeInTheDocument() + }) + + it('shows a recoverable error when the script fails to load', async () => { + const user = userEvent.setup() + const onInvalidate = vi.fn() + const onError = vi.fn() + render( + , + ) + const initialScriptSrc = mocks.scriptProps?.src + + act(() => { + mocks.scriptProps?.onError?.() + }) + + expect(screen.getByRole('alert')).toHaveTextContent('login.turnstile.loadError') + expect(onInvalidate).not.toHaveBeenCalled() + expect(onError).toHaveBeenCalledTimes(1) + + await user.click(screen.getByRole('button', { name: 'common.operation.retry' })) + + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(onError).toHaveBeenCalledTimes(1) + expect(mocks.scriptProps?.src).not.toBe(initialScriptSrc) + + mocks.render.mockReturnValue('widget-id') + Object.defineProperty(window, 'turnstile', { + configurable: true, + value: { + remove: mocks.remove, + render: mocks.render, + }, + }) + act(() => { + mocks.scriptProps?.onReady?.() + }) + + await waitFor(() => { + expect(mocks.render).toHaveBeenCalledTimes(1) + }) + }) + + it('recreates the widget after a challenge error without treating token expiry as a load failure', async () => { + const user = userEvent.setup() + const onInvalidate = vi.fn() + const onError = vi.fn() + mocks.render.mockImplementation((_container: HTMLElement, options: TurnstileOptions) => { + turnstileOptions = options + return 'widget-id' + }) + Object.defineProperty(window, 'turnstile', { + configurable: true, + value: { + remove: mocks.remove, + render: mocks.render, + }, + }) + render( + , + ) + + act(() => { + mocks.scriptProps?.onReady?.() + }) + act(() => { + turnstileOptions?.['expired-callback']() + }) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(onInvalidate).toHaveBeenCalledTimes(1) + expect(onError).not.toHaveBeenCalled() + + act(() => { + turnstileOptions?.['error-callback']('network-error') + }) + expect(screen.getByRole('alert')).toHaveTextContent('login.turnstile.loadError') + expect(onInvalidate).toHaveBeenCalledTimes(1) + expect(onError).toHaveBeenCalledTimes(1) + + await user.click(screen.getByRole('button', { name: 'common.operation.retry' })) + + expect(mocks.remove).toHaveBeenCalledWith('widget-id') + expect(mocks.render).toHaveBeenCalledTimes(2) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/signin/components/mail-and-code-auth.tsx b/web/app/signin/components/mail-and-code-auth.tsx index 7d9de58c3f3..423c2ca831c 100644 --- a/web/app/signin/components/mail-and-code-auth.tsx +++ b/web/app/signin/components/mail-and-code-auth.tsx @@ -2,13 +2,16 @@ import { Button } from '@langgenius/dify-ui/button' import { Field, FieldControl, FieldLabel } from '@langgenius/dify-ui/field' import { Form } from '@langgenius/dify-ui/form' import { toast } from '@langgenius/dify-ui/toast' +import { useSuspenseQuery } from '@tanstack/react-query' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { COUNT_DOWN_TIME_MS, useSetCountdownLeftTime } from '@/app/components/signin/storage' -import { emailRegex } from '@/config' +import { emailRegex, TURNSTILE_SITE_KEY } from '@/config' import { useLocale } from '@/context/i18n' +import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { useRouter, useSearchParams } from '@/next/navigation' import { sendEMailLoginCode } from '@/service/common' +import Turnstile from './turnstile' type MailAndCodeAuthProps = { isInvite: boolean @@ -18,13 +21,20 @@ export default function MailAndCodeAuth({ isInvite }: MailAndCodeAuthProps) { const { t } = useTranslation() const router = useRouter() const searchParams = useSearchParams() + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const emailFromLink = decodeURIComponent(searchParams.get('email') || '') const [email, setEmail] = useState(emailFromLink) const [loading, setLoading] = useState(false) + const [turnstileToken, setTurnstileToken] = useState('') + const [turnstileGeneration, setTurnstileGeneration] = useState(0) const locale = useLocale() const setCountdownLeftTime = useSetCountdownLeftTime() + const turnstileSiteKey = TURNSTILE_SITE_KEY.trim() + const isTurnstileRequired = systemFeatures.deployment_edition === 'CLOUD' + const shouldRenderTurnstile = isTurnstileRequired && Boolean(turnstileSiteKey) const handleGetEMailVerificationCode = async () => { + let shouldResetTurnstile = false try { if (!email) { toast.error(t(($) => $['error.emailEmpty'], { ns: 'login' })) @@ -36,18 +46,28 @@ export default function MailAndCodeAuth({ isInvite }: MailAndCodeAuthProps) { return } setLoading(true) - const ret = await sendEMailLoginCode(email, locale) + shouldResetTurnstile = isTurnstileRequired + const ret = await sendEMailLoginCode( + email, + locale, + isTurnstileRequired ? turnstileToken : undefined, + ) if (ret.result === 'success') { setCountdownLeftTime(`${COUNT_DOWN_TIME_MS}`) const params = new URLSearchParams(searchParams) params.set('email', encodeURIComponent(email)) params.set('token', encodeURIComponent(ret.data)) router.push(`/signin/check-code?${params.toString()}`) + shouldResetTurnstile = false } } catch (error) { console.error(error) } finally { setLoading(false) + if (shouldResetTurnstile) { + setTurnstileToken('') + setTurnstileGeneration((value) => value + 1) + } } } @@ -70,11 +90,24 @@ export default function MailAndCodeAuth({ isInvite }: MailAndCodeAuthProps) { placeholder={t(($) => $.emailPlaceholder, { ns: 'login' }) as string} onValueChange={setEmail} /> + {shouldRenderTurnstile && ( + { + setTurnstileToken('') + }} + onError={() => { + setTurnstileToken('') + }} + /> + )}
+
+ )} +
+