mirror of
https://github.com/langgenius/dify.git
synced 2026-09-08 02:43:49 +08:00
feat: add trunstile (#40494)
Co-authored-by: Joel <iamjoel007@gmail.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
(cherry picked from commit 059c9ce0b6)
This commit is contained in:
parent
0485667d22
commit
6b0c643ca6
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
34
api/configs/extra/turnstile_config.py
Normal file
34
api/configs/extra/turnstile_config.py
Normal file
@ -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(".")
|
||||
)
|
||||
@ -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."
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -6787,7 +6787,7 @@ Check if dataset is in use
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **application/json**: [EmailPayload](#emailpayload)<br> |
|
||||
| Yes | **application/json**: [EmailCodeSendPayload](#emailcodesendpayload)<br> |
|
||||
|
||||
#### 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 |
|
||||
|
||||
86
api/services/turnstile_service.py
Normal file
86
api/services/turnstile_service.py
Normal file
@ -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
|
||||
)
|
||||
@ -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)
|
||||
|
||||
@ -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")
|
||||
|
||||
122
api/tests/unit_tests/services/test_turnstile_service.py
Normal file
122
api/tests/unit_tests/services/test_turnstile_service.py
Normal file
@ -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)
|
||||
@ -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=
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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=
|
||||
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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<typeof proxy>[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',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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(<Countdown onResend={onResend} resendDisabled />)
|
||||
|
||||
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(<Countdown onResend={onResend} restartOnResend={false} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'login.checkCode.resend' }))
|
||||
|
||||
expect(localStorage.getItem(COUNT_DOWN_KEY)).toBe('0')
|
||||
expect(onResend).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 <CountdownFallback />
|
||||
|
||||
return (
|
||||
<Suspense fallback={<CountdownFallback />}>
|
||||
<CountdownContent onResend={onResend} />
|
||||
<CountdownContent
|
||||
onResend={onResend}
|
||||
resendDisabled={resendDisabled}
|
||||
restartOnResend={restartOnResend}
|
||||
/>
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@ -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 && (
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer border-none bg-transparent p-0 text-left system-xs-medium text-text-accent-secondary focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
|
||||
className="cursor-pointer border-none bg-transparent p-0 text-left system-xs-medium text-text-accent-secondary focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden disabled:cursor-not-allowed disabled:text-text-disabled"
|
||||
disabled={resendDisabled}
|
||||
onClick={resend}
|
||||
>
|
||||
{t(($) => $['checkCode.resend'], { ns: 'login' })}
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import type { GetAccountProfileResponse } from '@dify/contracts/api/console/account/types.gen'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { userProfileQueryOptions } from '@/features/account-profile/client'
|
||||
import { emailLoginWithCode } from '@/service/common'
|
||||
import { emailLoginWithCode, sendEMailLoginCode } from '@/service/common'
|
||||
import CheckCode from '../page'
|
||||
|
||||
const navigationMocks = vi.hoisted(() => ({
|
||||
@ -16,12 +16,56 @@ const serviceBaseMocks = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
}))
|
||||
|
||||
type ScriptProps = {
|
||||
id: string
|
||||
src: string
|
||||
onReady?: () => void
|
||||
onError?: () => void
|
||||
}
|
||||
|
||||
type TurnstileOptions = {
|
||||
callback: (token: string) => void
|
||||
}
|
||||
|
||||
const turnstileMocks = vi.hoisted(() => ({
|
||||
deploymentEdition: 'COMMUNITY',
|
||||
remove: vi.fn(),
|
||||
render: vi.fn(),
|
||||
scriptProps: undefined as ScriptProps | undefined,
|
||||
siteKey: '',
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/amplitude', () => ({
|
||||
trackEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/system-features/client', () => ({
|
||||
systemFeaturesQueryOptions: () => ({
|
||||
queryKey: ['system-features'],
|
||||
queryFn: () => Promise.resolve({ deployment_edition: turnstileMocks.deploymentEdition }),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/config', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/config')>()),
|
||||
get TURNSTILE_SITE_KEY() {
|
||||
return turnstileMocks.siteKey
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/signin/countdown', () => ({
|
||||
default: ({ onResend, resendDisabled }: { onResend?: () => void; resendDisabled?: boolean }) => (
|
||||
<button type="button" disabled={resendDisabled} onClick={onResend}>
|
||||
resend-code
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CheckCode />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CheckCode />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CheckCode />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
@ -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<HTMLInputElement>(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 (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="inline-flex size-14 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge shadow-lg">
|
||||
@ -139,7 +170,25 @@ export default function CheckCode() {
|
||||
>
|
||||
{t(($) => $['checkCode.verify'], { ns: 'login' })}
|
||||
</Button>
|
||||
<Countdown onResend={resendCode} />
|
||||
{shouldRenderResendTurnstile && (
|
||||
<Turnstile
|
||||
siteKey={turnstileSiteKey}
|
||||
onVerify={(turnstileToken) => {
|
||||
void resendCode(turnstileToken)
|
||||
}}
|
||||
onInvalidate={() => {
|
||||
setShowResendTurnstile(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Countdown
|
||||
key={countdownGeneration}
|
||||
onResend={handleResend}
|
||||
resendDisabled={
|
||||
isResending || showResendTurnstile || (isTurnstileRequired && !turnstileSiteKey)
|
||||
}
|
||||
restartOnResend={false}
|
||||
/>
|
||||
</form>
|
||||
<div className="py-2">
|
||||
<div className="h-px bg-linear-to-r from-background-gradient-mask-transparent via-divider-regular to-background-gradient-mask-transparent"></div>
|
||||
|
||||
226
web/app/signin/components/__tests__/mail-and-code-auth.spec.tsx
Normal file
226
web/app/signin/components/__tests__/mail-and-code-auth.spec.tsx
Normal file
@ -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(<MailAndCodeAuth isInvite={false} />, {
|
||||
systemFeatures: { deployment_edition: deploymentEdition },
|
||||
})
|
||||
|
||||
vi.mock('@/next/script', async () => {
|
||||
const { useEffect } = await vi.importActual<typeof import('react')>('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<typeof import('@/config')>('@/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()
|
||||
})
|
||||
})
|
||||
190
web/app/signin/components/__tests__/turnstile.spec.tsx
Normal file
190
web/app/signin/components/__tests__/turnstile.spec.tsx
Normal file
@ -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<typeof import('react')>('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<string, HTMLElement>()
|
||||
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(
|
||||
<StrictMode>
|
||||
<Turnstile siteKey="site-key" onVerify={vi.fn()} onInvalidate={vi.fn()} />
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
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(
|
||||
<Turnstile
|
||||
siteKey="site-key"
|
||||
onVerify={vi.fn()}
|
||||
onInvalidate={onInvalidate}
|
||||
onError={onError}
|
||||
/>,
|
||||
)
|
||||
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(
|
||||
<Turnstile
|
||||
siteKey="site-key"
|
||||
onVerify={vi.fn()}
|
||||
onInvalidate={onInvalidate}
|
||||
onError={onError}
|
||||
/>,
|
||||
)
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
@ -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 && (
|
||||
<Turnstile
|
||||
key={turnstileGeneration}
|
||||
siteKey={turnstileSiteKey}
|
||||
onVerify={setTurnstileToken}
|
||||
onInvalidate={() => {
|
||||
setTurnstileToken('')
|
||||
}}
|
||||
onError={() => {
|
||||
setTurnstileToken('')
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="mt-3">
|
||||
<Button
|
||||
type="submit"
|
||||
loading={loading}
|
||||
disabled={loading || !email}
|
||||
disabled={loading || !email || (isTurnstileRequired && !turnstileToken)}
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
>
|
||||
|
||||
159
web/app/signin/components/turnstile.tsx
Normal file
159
web/app/signin/components/turnstile.tsx
Normal file
@ -0,0 +1,159 @@
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Script from '@/next/script'
|
||||
|
||||
const TURNSTILE_SCRIPT_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'
|
||||
|
||||
type TurnstileRenderOptions = {
|
||||
sitekey: string
|
||||
action: string
|
||||
appearance: 'always'
|
||||
size: 'flexible'
|
||||
theme: 'auto'
|
||||
callback: (token: string) => void
|
||||
'error-callback': (errorCode: string) => boolean
|
||||
'expired-callback': () => void
|
||||
'timeout-callback': () => void
|
||||
'unsupported-callback': () => void
|
||||
}
|
||||
|
||||
type TurnstileApi = {
|
||||
render: (container: HTMLElement, options: TurnstileRenderOptions) => string
|
||||
remove: (widgetId: string) => void
|
||||
}
|
||||
|
||||
const getTurnstileApi = () => (window as Window & { turnstile?: TurnstileApi }).turnstile
|
||||
|
||||
type TurnstileProps = {
|
||||
siteKey: string
|
||||
onVerify: (token: string) => void
|
||||
onInvalidate: () => void
|
||||
onError?: () => void
|
||||
}
|
||||
|
||||
export default function Turnstile({ siteKey, onVerify, onInvalidate, onError }: TurnstileProps) {
|
||||
const { t } = useTranslation()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const onVerifyRef = useRef(onVerify)
|
||||
const onInvalidateRef = useRef(onInvalidate)
|
||||
const onErrorRef = useRef(onError)
|
||||
const [hasError, setHasError] = useState(false)
|
||||
const [isScriptReady, setIsScriptReady] = useState(false)
|
||||
const [scriptGeneration, setScriptGeneration] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
onVerifyRef.current = onVerify
|
||||
onInvalidateRef.current = onInvalidate
|
||||
onErrorRef.current = onError
|
||||
}, [onError, onInvalidate, onVerify])
|
||||
|
||||
const invalidate = useCallback(() => {
|
||||
onInvalidateRef.current()
|
||||
}, [])
|
||||
|
||||
const handleChallengeError = useCallback(() => {
|
||||
onErrorRef.current?.()
|
||||
setHasError(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isScriptReady || hasError) return
|
||||
|
||||
const turnstile = getTurnstileApi()
|
||||
const container = containerRef.current
|
||||
if (!container || !turnstile) return
|
||||
|
||||
let widgetId: string | undefined
|
||||
try {
|
||||
widgetId = turnstile.render(container, {
|
||||
sitekey: siteKey,
|
||||
action: 'signin_code',
|
||||
appearance: 'always',
|
||||
size: 'flexible',
|
||||
theme: 'auto',
|
||||
callback: (token) => {
|
||||
onVerifyRef.current(token)
|
||||
},
|
||||
'error-callback': () => {
|
||||
handleChallengeError()
|
||||
return true
|
||||
},
|
||||
'expired-callback': invalidate,
|
||||
'timeout-callback': invalidate,
|
||||
'unsupported-callback': handleChallengeError,
|
||||
})
|
||||
} catch {
|
||||
queueMicrotask(handleChallengeError)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (!widgetId) return
|
||||
turnstile.remove(widgetId)
|
||||
}
|
||||
}, [handleChallengeError, hasError, invalidate, isScriptReady, siteKey])
|
||||
|
||||
const handleScriptReady = () => {
|
||||
if (getTurnstileApi()) {
|
||||
setIsScriptReady(true)
|
||||
return
|
||||
}
|
||||
|
||||
setIsScriptReady(false)
|
||||
handleChallengeError()
|
||||
}
|
||||
|
||||
const handleScriptError = () => {
|
||||
setIsScriptReady(false)
|
||||
handleChallengeError()
|
||||
}
|
||||
|
||||
const handleRetry = () => {
|
||||
const canReuseLoadedScript = Boolean(getTurnstileApi())
|
||||
|
||||
setHasError(false)
|
||||
|
||||
if (canReuseLoadedScript) {
|
||||
setIsScriptReady(true)
|
||||
return
|
||||
}
|
||||
|
||||
setIsScriptReady(false)
|
||||
setScriptGeneration((current) => current + 1)
|
||||
}
|
||||
|
||||
const scriptId = scriptGeneration
|
||||
? `cloudflare-turnstile-${scriptGeneration}`
|
||||
: 'cloudflare-turnstile'
|
||||
const scriptSrc = scriptGeneration
|
||||
? `${TURNSTILE_SCRIPT_SRC}#retry-${scriptGeneration}`
|
||||
: TURNSTILE_SCRIPT_SRC
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-3 flex min-h-16.25 w-full items-center gap-3 rounded-xl border border-state-destructive-border bg-state-destructive-hover-alt p-3"
|
||||
>
|
||||
<span className="i-ri-error-warning-fill size-4 shrink-0 text-text-destructive" />
|
||||
<span className="grow system-xs-regular text-text-destructive">
|
||||
{t(($) => $['turnstile.loadError'], { ns: 'login' })}
|
||||
</span>
|
||||
<Button type="button" size="small" variant="secondary" onClick={handleRetry}>
|
||||
{t(($) => $['operation.retry'], { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div ref={containerRef} className={`mt-3 h-16.25 w-full ${hasError ? 'hidden' : ''}`} />
|
||||
<Script
|
||||
key={scriptGeneration}
|
||||
id={scriptId}
|
||||
src={scriptSrc}
|
||||
strategy="afterInteractive"
|
||||
onReady={handleScriptReady}
|
||||
onError={handleScriptError}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -27,6 +27,7 @@ export const MARKETPLACE_URL_PREFIX = getStringConfig(env.NEXT_PUBLIC_MARKETPLAC
|
||||
|
||||
export const AMPLITUDE_API_KEY = getStringConfig(env.NEXT_PUBLIC_AMPLITUDE_API_KEY, '')
|
||||
export const COOKIEYES_SITE_KEY = getStringConfig(env.NEXT_PUBLIC_COOKIEYES_SITE_KEY, '')
|
||||
export const TURNSTILE_SITE_KEY = getStringConfig(env.NEXT_PUBLIC_TURNSTILE_SITE_KEY, '')
|
||||
export const WEB_PREFIX = env.NEXT_PUBLIC_WEB_PREFIX
|
||||
|
||||
export const IS_DEV = process.env.NODE_ENV === 'development'
|
||||
|
||||
@ -142,6 +142,10 @@ const clientSchema = {
|
||||
* The maximum number of top-k value for RAG.
|
||||
*/
|
||||
NEXT_PUBLIC_TOP_K_MAX_VALUE: coercedNumber.default(10),
|
||||
/**
|
||||
* Cloudflare Turnstile site key for Dify Cloud sign-in verification.
|
||||
*/
|
||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY: z.string().optional(),
|
||||
/**
|
||||
* Disable Upload Image as WebApp icon default is false
|
||||
*/
|
||||
@ -291,6 +295,9 @@ export const env = createEnv({
|
||||
NEXT_PUBLIC_TOP_K_MAX_VALUE: isServer
|
||||
? process.env.NEXT_PUBLIC_TOP_K_MAX_VALUE
|
||||
: getRuntimeEnvFromBody('topKMaxValue'),
|
||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY: isServer
|
||||
? process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY
|
||||
: getRuntimeEnvFromBody('turnstileSiteKey'),
|
||||
NEXT_PUBLIC_UPLOAD_IMAGE_AS_ICON: isServer
|
||||
? process.env.NEXT_PUBLIC_UPLOAD_IMAGE_AS_ICON
|
||||
: getRuntimeEnvFromBody('uploadImageAsIcon'),
|
||||
|
||||
@ -91,6 +91,7 @@
|
||||
"timezone": "المنطقة الزمنية",
|
||||
"tos": "شروط الخدمة",
|
||||
"tosDesc": "بالتسجيل، فإنك توافق على",
|
||||
"turnstile.loadError": "تعذر تحميل التحقق الأمني.",
|
||||
"usePassword": "استخدام كلمة المرور",
|
||||
"useVerificationCode": "استخدام رمز التحقق",
|
||||
"validate": "تحقق",
|
||||
|
||||
@ -91,6 +91,7 @@
|
||||
"timezone": "Zeitzone",
|
||||
"tos": "Nutzungsbedingungen",
|
||||
"tosDesc": "Mit der Anmeldung stimmst du unseren",
|
||||
"turnstile.loadError": "Die Sicherheitsüberprüfung konnte nicht geladen werden.",
|
||||
"usePassword": "Passwort verwenden",
|
||||
"useVerificationCode": "Verifizierungscode verwenden",
|
||||
"validate": "Validieren",
|
||||
|
||||
@ -91,6 +91,7 @@
|
||||
"timezone": "Time zone",
|
||||
"tos": "Terms of Service",
|
||||
"tosDesc": "By signing up, you agree to our",
|
||||
"turnstile.loadError": "Security verification couldn't load.",
|
||||
"usePassword": "Use Password",
|
||||
"useVerificationCode": "Use Verification Code",
|
||||
"validate": "Validate",
|
||||
|
||||
@ -91,6 +91,7 @@
|
||||
"timezone": "Zona horaria",
|
||||
"tos": "Términos de servicio",
|
||||
"tosDesc": "Al registrarte, aceptas nuestros",
|
||||
"turnstile.loadError": "No se pudo cargar la verificación de seguridad.",
|
||||
"usePassword": "Usar contraseña",
|
||||
"useVerificationCode": "Usar código de verificación",
|
||||
"validate": "Validar",
|
||||
|
||||
@ -91,6 +91,7 @@
|
||||
"timezone": "منطقه زمانی",
|
||||
"tos": "شرایط خدمات",
|
||||
"tosDesc": "با ثبت نام، شما با شرایط ما موافقت میکنید",
|
||||
"turnstile.loadError": "تأیید امنیتی بارگیری نشد.",
|
||||
"usePassword": "از رمز عبور استفاده کنید",
|
||||
"useVerificationCode": "از کد تأیید استفاده کنید",
|
||||
"validate": "اعتبارسنجی",
|
||||
|
||||
@ -91,6 +91,7 @@
|
||||
"timezone": "Fuseau horaire",
|
||||
"tos": "Conditions de Service",
|
||||
"tosDesc": "En vous inscrivant, vous acceptez nos",
|
||||
"turnstile.loadError": "La vérification de sécurité n’a pas pu être chargée.",
|
||||
"usePassword": "Utiliser le mot de passe",
|
||||
"useVerificationCode": "Utiliser le code de vérification",
|
||||
"validate": "Valider",
|
||||
|
||||
@ -91,6 +91,7 @@
|
||||
"timezone": "समय क्षेत्र",
|
||||
"tos": "सेवा की शर्तें",
|
||||
"tosDesc": "साइन अप करके, आप हमारी सहमति देते हैं",
|
||||
"turnstile.loadError": "सुरक्षा सत्यापन लोड नहीं हो सका।",
|
||||
"usePassword": "पासवर्ड का उपयोग करें",
|
||||
"useVerificationCode": "सत्यापन कोड का उपयोग करें",
|
||||
"validate": "सत्यापित करें",
|
||||
|
||||
@ -91,6 +91,7 @@
|
||||
"timezone": "Zona waktu",
|
||||
"tos": "Ketentuan Layanan",
|
||||
"tosDesc": "Dengan mendaftar, Anda menyetujui",
|
||||
"turnstile.loadError": "Verifikasi keamanan tidak dapat dimuat.",
|
||||
"usePassword": "Gunakan Kata Sandi",
|
||||
"useVerificationCode": "Gunakan Kode Verifikasi",
|
||||
"validate": "Memvalidasi",
|
||||
|
||||
@ -91,6 +91,7 @@
|
||||
"timezone": "Fuso orario",
|
||||
"tos": "Termini di servizio",
|
||||
"tosDesc": "Iscrivendoti, accetti i nostri",
|
||||
"turnstile.loadError": "Impossibile caricare la verifica di sicurezza.",
|
||||
"usePassword": "Usa password",
|
||||
"useVerificationCode": "Usa il codice di verifica",
|
||||
"validate": "Convalida",
|
||||
|
||||
@ -91,6 +91,7 @@
|
||||
"timezone": "タイムゾーン",
|
||||
"tos": "利用規約",
|
||||
"tosDesc": "サインアップすることで、以下に同意するものとします",
|
||||
"turnstile.loadError": "セキュリティ確認を読み込めませんでした。",
|
||||
"usePassword": "パスワードを使用",
|
||||
"useVerificationCode": "確認コードを使用する",
|
||||
"validate": "検証",
|
||||
|
||||
@ -91,6 +91,7 @@
|
||||
"timezone": "시간대",
|
||||
"tos": "이용약관",
|
||||
"tosDesc": "가입함으로써 다음 내용에 동의하게 됩니다.",
|
||||
"turnstile.loadError": "보안 인증을 불러올 수 없습니다.",
|
||||
"usePassword": "비밀번호 사용",
|
||||
"useVerificationCode": "인증 코드 사용",
|
||||
"validate": "확인",
|
||||
|
||||
@ -91,6 +91,7 @@
|
||||
"timezone": "Time zone",
|
||||
"tos": "Terms of Service",
|
||||
"tosDesc": "By signing up, you agree to our",
|
||||
"turnstile.loadError": "De beveiligingsverificatie kon niet worden geladen.",
|
||||
"usePassword": "Use Password",
|
||||
"useVerificationCode": "Use Verification Code",
|
||||
"validate": "Validate",
|
||||
|
||||
@ -91,6 +91,7 @@
|
||||
"timezone": "Strefa czasowa",
|
||||
"tos": "Warunki świadczenia usług",
|
||||
"tosDesc": "Założeniem konta zgadzasz się z naszymi",
|
||||
"turnstile.loadError": "Nie udało się załadować weryfikacji bezpieczeństwa.",
|
||||
"usePassword": "Użyj hasła",
|
||||
"useVerificationCode": "Użyj kodu weryfikacyjnego",
|
||||
"validate": "Sprawdź",
|
||||
|
||||
@ -91,6 +91,7 @@
|
||||
"timezone": "Fuso horário",
|
||||
"tos": "Termos de Serviço",
|
||||
"tosDesc": "Ao se inscrever, você concorda com nossos",
|
||||
"turnstile.loadError": "Não foi possível carregar a verificação de segurança.",
|
||||
"usePassword": "Usar senha",
|
||||
"useVerificationCode": "Usar código de verificação",
|
||||
"validate": "Validar",
|
||||
|
||||
@ -91,6 +91,7 @@
|
||||
"timezone": "Fus orar",
|
||||
"tos": "Termeni și condiții",
|
||||
"tosDesc": "Prin înregistrarea, ești de acord cu",
|
||||
"turnstile.loadError": "Verificarea de securitate nu a putut fi încărcată.",
|
||||
"usePassword": "Utilizați parola",
|
||||
"useVerificationCode": "Utilizarea codului de verificare",
|
||||
"validate": "Validează",
|
||||
|
||||
@ -91,6 +91,7 @@
|
||||
"timezone": "Часовой пояс",
|
||||
"tos": "Условия обслуживания",
|
||||
"tosDesc": "Регистрируясь, вы соглашаетесь с нашими",
|
||||
"turnstile.loadError": "Не удалось загрузить проверку безопасности.",
|
||||
"usePassword": "Использовать пароль",
|
||||
"useVerificationCode": "Используйте код подтверждения",
|
||||
"validate": "Проверить",
|
||||
|
||||
@ -91,6 +91,7 @@
|
||||
"timezone": "Časovni pas",
|
||||
"tos": "Pogoji storitve",
|
||||
"tosDesc": "Z registracijo se strinjate z našimi",
|
||||
"turnstile.loadError": "Varnostnega preverjanja ni bilo mogoče naložiti.",
|
||||
"usePassword": "Uporaba gesla",
|
||||
"useVerificationCode": "Uporaba kode za preverjanje",
|
||||
"validate": "Potrdi",
|
||||
|
||||
@ -91,6 +91,7 @@
|
||||
"timezone": "เขตเวลา",
|
||||
"tos": "ข้อกําหนดในการให้บริการ",
|
||||
"tosDesc": "การลงทะเบียนแสดงว่าคุณยอมรับ",
|
||||
"turnstile.loadError": "ไม่สามารถโหลดการตรวจสอบความปลอดภัยได้",
|
||||
"usePassword": "ใช้รหัสผ่าน",
|
||||
"useVerificationCode": "ใช้รหัสยืนยัน",
|
||||
"validate": "ตรวจ สอบ",
|
||||
|
||||
@ -91,6 +91,7 @@
|
||||
"timezone": "Zaman dilimi",
|
||||
"tos": "Hizmet Şartları",
|
||||
"tosDesc": "Kaydolarak, Hizmet Şartlarımızı kabul etmiş olursunuz",
|
||||
"turnstile.loadError": "Güvenlik doğrulaması yüklenemedi.",
|
||||
"usePassword": "Şifre Kullan",
|
||||
"useVerificationCode": "Doğrulama Kodunu Kullan",
|
||||
"validate": "Doğrula",
|
||||
|
||||
@ -91,6 +91,7 @@
|
||||
"timezone": "Часовий пояс",
|
||||
"tos": "Умови обслуговування",
|
||||
"tosDesc": "Реєструючись, ви приймаєте наші",
|
||||
"turnstile.loadError": "Не вдалося завантажити перевірку безпеки.",
|
||||
"usePassword": "Використовуйте пароль",
|
||||
"useVerificationCode": "Використовуйте код підтвердження",
|
||||
"validate": "Перевірити",
|
||||
|
||||
@ -91,6 +91,7 @@
|
||||
"timezone": "Múi giờ",
|
||||
"tos": "Điều khoản dịch vụ",
|
||||
"tosDesc": "Bằng cách đăng ký, bạn đồng ý với",
|
||||
"turnstile.loadError": "Không thể tải xác minh bảo mật.",
|
||||
"usePassword": "Sử dụng mật khẩu",
|
||||
"useVerificationCode": "Sử dụng mã xác minh",
|
||||
"validate": "Xác thực",
|
||||
|
||||
@ -91,6 +91,7 @@
|
||||
"timezone": "时区",
|
||||
"tos": "使用协议",
|
||||
"tosDesc": "使用即代表您同意我们的",
|
||||
"turnstile.loadError": "无法加载安全验证。",
|
||||
"usePassword": "使用密码登录",
|
||||
"useVerificationCode": "使用验证码登录",
|
||||
"validate": "验证",
|
||||
|
||||
@ -91,6 +91,7 @@
|
||||
"timezone": "時區",
|
||||
"tos": "使用協議",
|
||||
"tosDesc": "使用即代表你並同意我們的",
|
||||
"turnstile.loadError": "無法載入安全驗證。",
|
||||
"usePassword": "使用密碼",
|
||||
"useVerificationCode": "使用驗證碼",
|
||||
"validate": "驗證",
|
||||
|
||||
@ -43,7 +43,10 @@ export function proxy(request: NextRequest) {
|
||||
return wrapResponseWithXFrameOptions(response, pathname)
|
||||
}
|
||||
|
||||
const whiteList = `${env.NEXT_PUBLIC_CSP_WHITELIST} ${NECESSARY_DOMAIN}`
|
||||
const turnstileOrigin = env.NEXT_PUBLIC_TURNSTILE_SITE_KEY
|
||||
? ' https://challenges.cloudflare.com'
|
||||
: ''
|
||||
const whiteList = `${env.NEXT_PUBLIC_CSP_WHITELIST} ${NECESSARY_DOMAIN}${turnstileOrigin}`
|
||||
const nonce = Buffer.from(crypto.randomUUID()).toString('base64')
|
||||
const csp = `'nonce-${nonce}'`
|
||||
|
||||
|
||||
42
web/service/common.spec.ts
Normal file
42
web/service/common.spec.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { sendEMailLoginCode } from './common'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
post: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./base', () => ({
|
||||
del: vi.fn(),
|
||||
get: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
post: mocks.post,
|
||||
}))
|
||||
|
||||
describe('sendEMailLoginCode', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('includes the Turnstile token when provided', async () => {
|
||||
await sendEMailLoginCode('user@example.com', 'en-US', 'turnstile-token')
|
||||
|
||||
expect(mocks.post).toHaveBeenCalledWith('/email-code-login', {
|
||||
body: {
|
||||
email: 'user@example.com',
|
||||
language: 'en-US',
|
||||
turnstile_token: 'turnstile-token',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('omits the Turnstile token when it is not provided', async () => {
|
||||
await sendEMailLoginCode('user@example.com', 'en-US')
|
||||
|
||||
expect(mocks.post).toHaveBeenCalledWith('/email-code-login', {
|
||||
body: {
|
||||
email: 'user@example.com',
|
||||
language: 'en-US',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -235,8 +235,15 @@ export const uploadRemoteFileInfo = (
|
||||
export const sendEMailLoginCode = (
|
||||
email: string,
|
||||
language = 'en-US',
|
||||
turnstileToken?: string,
|
||||
): Promise<CommonResponse & { data: string }> =>
|
||||
post<CommonResponse & { data: string }>('/email-code-login', { body: { email, language } })
|
||||
post<CommonResponse & { data: string }>('/email-code-login', {
|
||||
body: {
|
||||
email,
|
||||
language,
|
||||
...(turnstileToken === undefined ? {} : { turnstile_token: turnstileToken }),
|
||||
},
|
||||
})
|
||||
|
||||
export const emailLoginWithCode = (data: {
|
||||
email: string
|
||||
|
||||
Loading…
Reference in New Issue
Block a user