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:
zyssyz123 2026-08-12 11:08:19 +08:00 committed by Joel
parent 0485667d22
commit 6b0c643ca6
53 changed files with 1510 additions and 50 deletions

View File

@ -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

View File

@ -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

View 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(".")
)

View File

@ -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."

View File

@ -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:

View File

@ -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 |

View 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
)

View File

@ -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)

View File

@ -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")

View 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)

View File

@ -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=

View File

@ -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
}
}
}
}

View File

@ -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'

View File

@ -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

View File

@ -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=

View File

@ -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')
})
})

View File

@ -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',
)
})
})

View File

@ -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()
})
})

View File

@ -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' })}

View File

@ -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()

View File

@ -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>

View 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()
})
})

View 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()
})
})

View File

@ -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"
>

View 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}
/>
</>
)
}

View File

@ -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'

View File

@ -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'),

View File

@ -91,6 +91,7 @@
"timezone": "المنطقة الزمنية",
"tos": "شروط الخدمة",
"tosDesc": "بالتسجيل، فإنك توافق على",
"turnstile.loadError": "تعذر تحميل التحقق الأمني.",
"usePassword": "استخدام كلمة المرور",
"useVerificationCode": "استخدام رمز التحقق",
"validate": "تحقق",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -91,6 +91,7 @@
"timezone": "منطقه زمانی",
"tos": "شرایط خدمات",
"tosDesc": "با ثبت نام، شما با شرایط ما موافقت می‌کنید",
"turnstile.loadError": "تأیید امنیتی بارگیری نشد.",
"usePassword": "از رمز عبور استفاده کنید",
"useVerificationCode": "از کد تأیید استفاده کنید",
"validate": "اعتبارسنجی",

View File

@ -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é na pas pu être chargée.",
"usePassword": "Utiliser le mot de passe",
"useVerificationCode": "Utiliser le code de vérification",
"validate": "Valider",

View File

@ -91,6 +91,7 @@
"timezone": "समय क्षेत्र",
"tos": "सेवा की शर्तें",
"tosDesc": "साइन अप करके, आप हमारी सहमति देते हैं",
"turnstile.loadError": "सुरक्षा सत्यापन लोड नहीं हो सका।",
"usePassword": "पासवर्ड का उपयोग करें",
"useVerificationCode": "सत्यापन कोड का उपयोग करें",
"validate": "सत्यापित करें",

View File

@ -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",

View File

@ -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",

View File

@ -91,6 +91,7 @@
"timezone": "タイムゾーン",
"tos": "利用規約",
"tosDesc": "サインアップすることで、以下に同意するものとします",
"turnstile.loadError": "セキュリティ確認を読み込めませんでした。",
"usePassword": "パスワードを使用",
"useVerificationCode": "確認コードを使用する",
"validate": "検証",

View File

@ -91,6 +91,7 @@
"timezone": "시간대",
"tos": "이용약관",
"tosDesc": "가입함으로써 다음 내용에 동의하게 됩니다.",
"turnstile.loadError": "보안 인증을 불러올 수 없습니다.",
"usePassword": "비밀번호 사용",
"useVerificationCode": "인증 코드 사용",
"validate": "확인",

View File

@ -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",

View File

@ -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ź",

View File

@ -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",

View File

@ -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ă",

View File

@ -91,6 +91,7 @@
"timezone": "Часовой пояс",
"tos": "Условия обслуживания",
"tosDesc": "Регистрируясь, вы соглашаетесь с нашими",
"turnstile.loadError": "Не удалось загрузить проверку безопасности.",
"usePassword": "Использовать пароль",
"useVerificationCode": "Используйте код подтверждения",
"validate": "Проверить",

View File

@ -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",

View File

@ -91,6 +91,7 @@
"timezone": "เขตเวลา",
"tos": "ข้อกําหนดในการให้บริการ",
"tosDesc": "การลงทะเบียนแสดงว่าคุณยอมรับ",
"turnstile.loadError": "ไม่สามารถโหลดการตรวจสอบความปลอดภัยได้",
"usePassword": "ใช้รหัสผ่าน",
"useVerificationCode": "ใช้รหัสยืนยัน",
"validate": "ตรวจ สอบ",

View File

@ -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",

View File

@ -91,6 +91,7 @@
"timezone": "Часовий пояс",
"tos": "Умови обслуговування",
"tosDesc": "Реєструючись, ви приймаєте наші",
"turnstile.loadError": "Не вдалося завантажити перевірку безпеки.",
"usePassword": "Використовуйте пароль",
"useVerificationCode": "Використовуйте код підтвердження",
"validate": "Перевірити",

View File

@ -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",

View File

@ -91,6 +91,7 @@
"timezone": "时区",
"tos": "使用协议",
"tosDesc": "使用即代表您同意我们的",
"turnstile.loadError": "无法加载安全验证。",
"usePassword": "使用密码登录",
"useVerificationCode": "使用验证码登录",
"validate": "验证",

View File

@ -91,6 +91,7 @@
"timezone": "時區",
"tos": "使用協議",
"tosDesc": "使用即代表你並同意我們的",
"turnstile.loadError": "無法載入安全驗證。",
"usePassword": "使用密碼",
"useVerificationCode": "使用驗證碼",
"validate": "驗證",

View File

@ -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}'`

View 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',
},
})
})
})

View File

@ -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