From 414f211cde94167c88a5bd44fbd7365aaabf11b8 Mon Sep 17 00:00:00 2001 From: zyssyz123 <916125788@qq.com> Date: Wed, 19 Aug 2026 06:57:47 +0000 Subject: [PATCH] fix(auth): harden email code login verification (#40960) --- api/.env.example | 4 + api/configs/extra/turnstile_config.py | 7 + api/configs/feature/__init__.py | 4 + api/controllers/console/auth/error.py | 6 + api/controllers/console/auth/login.py | 76 +++-- api/openapi/markdown/console-openapi.md | 3 +- api/services/account_service.py | 15 +- api/services/email_code_login_challenge.py | 268 ++++++++++++++++++ api/services/turnstile_service.py | 13 +- .../unit_tests/configs/test_dify_config.py | 8 + .../console/auth/test_email_verification.py | 253 ++++++++++++++--- .../console/auth/test_login_logout.py | 28 +- .../test_email_code_login_challenge.py | 174 ++++++++++++ .../services/test_turnstile_service.py | 14 + docker/envs/core-services/api.env.example | 1 + docker/envs/core-services/shared.env.example | 2 + .../api/console/email-code-login/types.gen.ts | 1 + .../api/console/email-code-login/zod.gen.ts | 5 +- .../signin/check-code/__tests__/page.spec.tsx | 251 ++++++++++++++-- web/app/signin/check-code/page.tsx | 68 +++-- .../components/__tests__/turnstile.spec.tsx | 9 +- .../signin/components/mail-and-code-auth.tsx | 1 + web/app/signin/components/turnstile.tsx | 15 +- web/service/common.spec.ts | 30 +- web/service/common.ts | 10 +- 25 files changed, 1141 insertions(+), 125 deletions(-) create mode 100644 api/services/email_code_login_challenge.py create mode 100644 api/tests/unit_tests/services/test_email_code_login_challenge.py diff --git a/api/.env.example b/api/.env.example index 5bc4cffeb17..65e9fa6c9a2 100644 --- a/api/.env.example +++ b/api/.env.example @@ -492,6 +492,8 @@ SENTRY_DSN= TURNSTILE_SECRET_KEY= # Comma-separated parent or exact hostnames, for example: dify.ai,staging.dify.dev TURNSTILE_ALLOWED_HOSTNAMES= +# Enable only after the compatible web client has been fully deployed. +TURNSTILE_EMAIL_CODE_VERIFY_REQUIRED=false # DEBUG DEBUG=false @@ -733,6 +735,8 @@ RESET_PASSWORD_TOKEN_EXPIRY_MINUTES=5 EMAIL_REGISTER_TOKEN_EXPIRY_MINUTES=5 CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES=5 OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES=5 +EMAIL_CODE_LOGIN_TOKEN_EXPIRY_MINUTES=5 +EMAIL_CODE_LOGIN_MAX_ATTEMPTS=5 CREATE_TIDB_SERVICE_JOB_ENABLED=false diff --git a/api/configs/extra/turnstile_config.py b/api/configs/extra/turnstile_config.py index c4ae92924e3..05614516784 100644 --- a/api/configs/extra/turnstile_config.py +++ b/api/configs/extra/turnstile_config.py @@ -13,6 +13,13 @@ class TurnstileConfig(BaseSettings): default="", description="Comma-separated parent or exact hostnames accepted from Turnstile.", ) + TURNSTILE_EMAIL_CODE_VERIFY_REQUIRED: bool = Field( + default=False, + description=( + "Require a separate Turnstile challenge when verifying email login codes on Dify Cloud. " + "Enable after the compatible web client has been deployed." + ), + ) @field_validator("TURNSTILE_SECRET_KEY", mode="before") @classmethod diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index a4cefb4ba40..06ae6c2718e 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -1549,6 +1549,10 @@ class LoginConfig(BaseSettings): description="expiry time in minutes for email code login token", default=5, ) + EMAIL_CODE_LOGIN_MAX_ATTEMPTS: PositiveInt = Field( + description="maximum number of verification attempts for an email code login challenge", + default=5, + ) ALLOW_REGISTER: bool = Field( description="whether to enable register", default=False, diff --git a/api/controllers/console/auth/error.py b/api/controllers/console/auth/error.py index 514450b343d..4ff9b4d33bc 100644 --- a/api/controllers/console/auth/error.py +++ b/api/controllers/console/auth/error.py @@ -95,6 +95,12 @@ class EmailCodeError(BaseHTTPException): code = 400 +class EmailCodeLoginServiceUnavailableError(BaseHTTPException): + error_code = "email_code_login_service_unavailable" + description = "Email code verification is temporarily unavailable. Please try again later." + code = 503 + + class EmailOrPasswordMismatchError(BaseHTTPException): error_code = "email_or_password_mismatch" description = "The email or password is mismatched." diff --git a/api/controllers/console/auth/login.py b/api/controllers/console/auth/login.py index 15558a0c146..6629784afaf 100644 --- a/api/controllers/console/auth/login.py +++ b/api/controllers/console/auth/login.py @@ -1,4 +1,5 @@ import logging +from uuid import UUID import flask_login from flask import make_response, request @@ -22,6 +23,7 @@ from controllers.console import console_ns from controllers.console.auth.error import ( AuthenticationFailedError, EmailCodeError, + EmailCodeLoginServiceUnavailableError, EmailPasswordLoginLimitError, InvalidEmailError, InvalidTokenError, @@ -61,6 +63,10 @@ from libs.token import ( from models.account import Account from services.account_service import AccountService, InvitationDetailDict, RegisterService, TenantService from services.billing_service import BillingService +from services.email_code_login_challenge import ( + EmailCodeLoginChallengeStatus, + EmailCodeLoginChallengeUnavailableError, +) from services.entities.auth_entities import LoginFailureReason, LoginPayloadBase from services.errors.account import ( AccountRegisterError, @@ -71,6 +77,7 @@ from services.errors.account import ( from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError from services.feature_service import FeatureService from services.turnstile_service import ( + EMAIL_CODE_VERIFY_ACTION, TurnstileChallengeRejectedError, TurnstileService, TurnstileUpstreamError, @@ -92,14 +99,20 @@ class EmailPayload(BaseModel): class EmailCodeSendPayload(EmailPayload): turnstile_token: str | None = Field( default=None, + max_length=2048, description="Cloudflare Turnstile token. Required at runtime for Dify Cloud.", ) class EmailCodeLoginPayload(BaseModel): email: EmailStr = Field(...) - code: str = Field(...) - token: str = Field(...) + code: str + token: UUID + turnstile_token: str | None = Field( + default=None, + max_length=2048, + description="Cloudflare Turnstile token for email-code verification.", + ) language: str | None = Field(default=None) timezone: str | None = Field(default=None) @@ -310,23 +323,55 @@ class EmailCodeLoginApi(Resource): original_email = req_data.email user_email = original_email.lower() language = req_data.language + ip_address = extract_remote_ip(request) - token_data = AccountService.get_email_code_login_data(req_data.token) - if token_data is None: - _log_console_login_failure(email=user_email, reason=LoginFailureReason.INVALID_EMAIL_CODE_TOKEN) - raise InvalidTokenError() - - token_email = token_data.get("email") - normalized_token_email = token_email.lower() if isinstance(token_email, str) else token_email - if normalized_token_email != user_email: - _log_console_login_failure(email=user_email, reason=LoginFailureReason.EMAIL_CODE_EMAIL_MISMATCH) - raise InvalidEmailError() - - if token_data["code"] != req_data.code: + # ``code`` is Base64 on the wire and is decoded by + # ``decrypt_code_field`` before model validation reaches this handler. + if len(req_data.code) != 6 or not req_data.code.isascii() or not req_data.code.isdigit(): + _log_console_login_failure(email=user_email, reason=LoginFailureReason.INVALID_EMAIL_CODE) + raise EmailCodeError() + + if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and ( + dify_config.TURNSTILE_EMAIL_CODE_VERIFY_REQUIRED or req_data.turnstile_token + ): + try: + TurnstileService.verify( + token=req_data.turnstile_token, + remote_ip=ip_address, + expected_action=EMAIL_CODE_VERIFY_ACTION, + ) + except TurnstileChallengeRejectedError as exc: + logger.info("Turnstile rejected an email-code verification challenge") + raise TurnstileVerificationFailedError() from exc + except TurnstileUpstreamError as exc: + logger.warning("Turnstile verification is unavailable", exc_info=True) + raise TurnstileServiceUnavailableError() from exc + + try: + verification = AccountService.verify_email_code_login_challenge( + email=user_email, + code=req_data.code, + token=str(req_data.token), + ) + except EmailCodeLoginChallengeUnavailableError as exc: + logger.warning("Email-code challenge verification is unavailable", exc_info=True) + raise EmailCodeLoginServiceUnavailableError() from exc + + if verification.status == EmailCodeLoginChallengeStatus.INVALID_TOKEN: + _log_console_login_failure(email=user_email, reason=LoginFailureReason.INVALID_EMAIL_CODE_TOKEN) + raise InvalidTokenError() + + if verification.status == EmailCodeLoginChallengeStatus.EMAIL_MISMATCH: + _log_console_login_failure(email=user_email, reason=LoginFailureReason.EMAIL_CODE_EMAIL_MISMATCH) + raise InvalidEmailError() + + if verification.status in { + EmailCodeLoginChallengeStatus.INVALID_CODE, + EmailCodeLoginChallengeStatus.EXHAUSTED, + }: _log_console_login_failure(email=user_email, reason=LoginFailureReason.INVALID_EMAIL_CODE) raise EmailCodeError() - AccountService.revoke_email_code_login_token(req_data.token) try: account = _get_account_with_case_fallback(original_email) except Unauthorized as exc: @@ -346,7 +391,6 @@ class EmailCodeLoginApi(Resource): else: TenantService.create_owner_tenant(account, session=db.session()) - ip_address = extract_remote_ip(request) if account is None: try: account = AccountService.create_account_and_tenant( diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 29559ee641a..6590baf3326 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -17208,7 +17208,8 @@ Portable DSL reference that could not be restored in the target workspace. | email | string | | Yes | | language | string | | No | | timezone | string | | No | -| token | string | | Yes | +| token | string (uuid) | | Yes | +| turnstile_token | string | Cloudflare Turnstile token for email-code verification. | No | #### EmailCodeSendPayload diff --git a/api/services/account_service.py b/api/services/account_service.py index 66ca44342da..eec13a8310a 100644 --- a/api/services/account_service.py +++ b/api/services/account_service.py @@ -48,6 +48,10 @@ from models.account import ( from models.dataset import Dataset from models.model import App, DifySetup from services.billing_service import BillingService +from services.email_code_login_challenge import ( + EmailCodeLoginChallengeResult, + EmailCodeLoginChallengeStore, +) from services.enterprise.rbac_service import ListOption, RBACService from services.entities.auth_entities import ( ChangeEmailNewEmailToken, @@ -1017,14 +1021,17 @@ class AccountService: email = account.email if account else email if email is None: raise ValueError("Email must be provided.") + email = email.lower() if cls.email_code_login_rate_limiter.is_rate_limited(email): from controllers.console.auth.error import EmailCodeLoginRateLimitExceededError raise EmailCodeLoginRateLimitExceededError(int(cls.email_code_login_rate_limiter.time_window / 60)) code = "".join([str(secrets.randbelow(exclusive_upper_bound=10)) for _ in range(6)]) - token = TokenManager.generate_token( - account=account, email=email, token_type="email_code_login", additional_data={"code": code} + token = EmailCodeLoginChallengeStore.create( + account_id=str(account.id) if account else None, + email=email, + code=code, ) send_email_code_login_mail_task.delay( language=language, @@ -1052,6 +1059,10 @@ class AccountService: def get_email_code_login_data(cls, token: str) -> dict[str, Any] | None: return TokenManager.get_token_data(token, "email_code_login") + @classmethod + def verify_email_code_login_challenge(cls, *, email: str, code: str, token: str) -> EmailCodeLoginChallengeResult: + return EmailCodeLoginChallengeStore.verify(email=email, code=code, token=token) + @classmethod def revoke_email_code_login_token(cls, token: str): TokenManager.revoke_token(token, "email_code_login") diff --git a/api/services/email_code_login_challenge.py b/api/services/email_code_login_challenge.py new file mode 100644 index 00000000000..f7e09c95514 --- /dev/null +++ b/api/services/email_code_login_challenge.py @@ -0,0 +1,268 @@ +from __future__ import annotations + +import json +import uuid +from dataclasses import dataclass +from enum import IntEnum, StrEnum +from hashlib import sha256 + +from redis.exceptions import RedisError + +from configs import dify_config +from extensions.ext_redis import redis_client +from extensions.redis_names import serialize_redis_name + +_TOKEN_TYPE = "email_code_login" +_CHALLENGE_VERSION = 2 + + +# The per-email v2 challenge is the sole state for tokens created by this +# implementation. Lua result codes must stay in sync with ``_LuaResult``. +_VERIFY_CHALLENGE_LUA = """ +local raw = redis.call('GET', KEYS[1]) +if not raw then + return {0, -1} +end + +local decoded, data = pcall(cjson.decode, raw) +if not decoded or type(data) ~= 'table' then + return {5, -1} +end + +if data.token_type ~= ARGV[1] or tonumber(data.challenge_version) ~= tonumber(ARGV[5]) then + return {5, -1} +end + +if data.state == 'consumed' or data.state == 'exhausted' then + return {8, -1} +end + +if type(data.token) ~= 'string' or data.token ~= ARGV[2] then + return {1, -1} +end + +if type(data.email) ~= 'string' or data.email ~= ARGV[3] then + return {2, -1} +end + +if type(data.code) ~= 'string' then + return {5, -1} +end + +local remaining = tonumber(data.remaining_attempts) +if not remaining or remaining <= 0 then + local tombstone = { + token_type = data.token_type, + challenge_version = data.challenge_version, + state = 'exhausted', + remaining_attempts = 0 + } + redis.call('SET', KEYS[1], cjson.encode(tombstone), 'KEEPTTL') + return {6, 0} +end + +if data.code == ARGV[4] then + local tombstone = { + token_type = data.token_type, + challenge_version = data.challenge_version, + state = 'consumed', + remaining_attempts = 0 + } + redis.call('SET', KEYS[1], cjson.encode(tombstone), 'KEEPTTL') + return {4, -1} +end + +remaining = remaining - 1 +if remaining <= 0 then + local tombstone = { + token_type = data.token_type, + challenge_version = data.challenge_version, + state = 'exhausted', + remaining_attempts = 0 + } + redis.call('SET', KEYS[1], cjson.encode(tombstone), 'KEEPTTL') + return {6, 0} +end + +data.remaining_attempts = remaining +redis.call('SET', KEYS[1], cjson.encode(data), 'KEEPTTL') +return {3, remaining} +""" + + +# Tokens created before this deployment only have the legacy per-token key. +# This fallback gives those in-flight tokens the same atomic attempt budget. +# A versioned token is never accepted here, so a consumed v2 challenge cannot +# fall back even if a stale legacy key is present unexpectedly. +_VERIFY_LEGACY_TOKEN_LUA = """ +local raw = redis.call('GET', KEYS[1]) +if not raw then + return {0, -1} +end + +local decoded, data = pcall(cjson.decode, raw) +if not decoded or type(data) ~= 'table' then + return {5, -1} +end + +if data.token_type ~= ARGV[1] or type(data.email) ~= 'string' or type(data.code) ~= 'string' then + return {5, -1} +end + +if string.lower(data.email) ~= ARGV[2] then + return {2, -1} +end + +if data.challenge_version ~= nil then + return {7, -1} +end + +local remaining = tonumber(data.remaining_attempts) +if not remaining then + remaining = tonumber(ARGV[4]) +end +if not remaining or remaining <= 0 then + redis.call('DEL', KEYS[1]) + return {6, 0} +end + +if data.code == ARGV[3] then + redis.call('DEL', KEYS[1]) + return {4, -1} +end + +remaining = remaining - 1 +if remaining <= 0 then + redis.call('DEL', KEYS[1]) + return {6, 0} +end + +data.remaining_attempts = remaining +redis.call('SET', KEYS[1], cjson.encode(data), 'KEEPTTL') +return {3, remaining} +""" + + +class EmailCodeLoginChallengeStatus(StrEnum): + VERIFIED = "verified" + INVALID_TOKEN = "invalid_token" + EMAIL_MISMATCH = "email_mismatch" + INVALID_CODE = "invalid_code" + EXHAUSTED = "exhausted" + + +@dataclass(frozen=True) +class EmailCodeLoginChallengeResult: + status: EmailCodeLoginChallengeStatus + remaining_attempts: int | None = None + + +class EmailCodeLoginChallengeUnavailableError(RuntimeError): + """The Redis-backed email-code challenge could not be safely evaluated.""" + + +class _LuaResult(IntEnum): + MISSING = 0 + TOKEN_MISMATCH = 1 + EMAIL_MISMATCH = 2 + INVALID_CODE = 3 + VERIFIED = 4 + CORRUPT = 5 + EXHAUSTED = 6 + VERSIONED_LEGACY_TOKEN = 7 + TERMINAL_CHALLENGE = 8 + + +class EmailCodeLoginChallengeStore: + @classmethod + def create(cls, *, email: str, code: str, account_id: str | None) -> str: + normalized_email = email.lower() + token = str(uuid.uuid4()) + payload = { + "account_id": account_id, + "email": normalized_email, + "token_type": _TOKEN_TYPE, + "code": code, + "remaining_attempts": dify_config.EMAIL_CODE_LOGIN_MAX_ATTEMPTS, + "challenge_version": _CHALLENGE_VERSION, + "state": "active", + "token": token, + } + expiry_seconds = int(dify_config.EMAIL_CODE_LOGIN_TOKEN_EXPIRY_MINUTES * 60) + + try: + # Overwriting this one key makes a resend invalidate the previous + # token for the normalized email without creating extra budgets. + redis_client.setex( + cls._challenge_key(normalized_email), + expiry_seconds, + json.dumps(payload, separators=(",", ":")), + ) + except RedisError as exc: + raise EmailCodeLoginChallengeUnavailableError("Could not create email-code challenge") from exc + + return token + + @classmethod + def verify(cls, *, email: str, code: str, token: str) -> EmailCodeLoginChallengeResult: + normalized_email = email.lower() + max_attempts = dify_config.EMAIL_CODE_LOGIN_MAX_ATTEMPTS + + try: + challenge_result = cls._eval( + _VERIFY_CHALLENGE_LUA, + cls._challenge_key(normalized_email), + _TOKEN_TYPE, + token, + normalized_email, + code, + _CHALLENGE_VERSION, + ) + if challenge_result[0] is not _LuaResult.MISSING: + return cls._to_public_result(challenge_result) + + # Only a token created before this deployment can reach the + # legacy fallback because new tokens are never written there. + legacy_result = cls._eval( + _VERIFY_LEGACY_TOKEN_LUA, + cls._legacy_token_key(token), + _TOKEN_TYPE, + normalized_email, + code, + max_attempts, + ) + return cls._to_public_result(legacy_result) + except (RedisError, TypeError, ValueError) as exc: + raise EmailCodeLoginChallengeUnavailableError("Could not verify email-code challenge") from exc + + @staticmethod + def _eval(script: str, key: str, *args: str | int) -> tuple[_LuaResult, int | None]: + # ``eval`` is delegated to the raw Redis client, so unlike the wrapper's + # normal commands it needs an explicitly serialized physical key. + response = redis_client.eval(script, 1, serialize_redis_name(key), *args) + if not isinstance(response, (list, tuple)) or len(response) != 2: + raise ValueError("Unexpected Redis Lua response") + + lua_result = _LuaResult(int(response[0])) + remaining = int(response[1]) + return lua_result, remaining if remaining >= 0 else None + + @staticmethod + def _to_public_result(result: tuple[_LuaResult, int | None]) -> EmailCodeLoginChallengeResult: + lua_result, remaining = result + status = { + _LuaResult.VERIFIED: EmailCodeLoginChallengeStatus.VERIFIED, + _LuaResult.EMAIL_MISMATCH: EmailCodeLoginChallengeStatus.EMAIL_MISMATCH, + _LuaResult.INVALID_CODE: EmailCodeLoginChallengeStatus.INVALID_CODE, + _LuaResult.EXHAUSTED: EmailCodeLoginChallengeStatus.EXHAUSTED, + }.get(lua_result, EmailCodeLoginChallengeStatus.INVALID_TOKEN) + return EmailCodeLoginChallengeResult(status=status, remaining_attempts=remaining) + + @staticmethod + def _challenge_key(normalized_email: str) -> str: + email_digest = sha256(normalized_email.encode("utf-8")).hexdigest() + return f"email_code_login:challenge:{{{email_digest}}}" + + @staticmethod + def _legacy_token_key(token: str) -> str: + return f"{_TOKEN_TYPE}:token:{token}" diff --git a/api/services/turnstile_service.py b/api/services/turnstile_service.py index 84634b2310a..a1da315831a 100644 --- a/api/services/turnstile_service.py +++ b/api/services/turnstile_service.py @@ -7,7 +7,8 @@ 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" +EMAIL_CODE_SEND_ACTION = "signin_code" +EMAIL_CODE_VERIFY_ACTION = "signin_code_verify" _MAX_TOKEN_LENGTH = 2048 _CLIENT_ERROR_CODES = frozenset( { @@ -44,7 +45,13 @@ class _TurnstileResponse(BaseModel): class TurnstileService: @classmethod - def verify(cls, *, token: str | None, remote_ip: str | None) -> None: + def verify( + cls, + *, + token: str | None, + remote_ip: str | None, + expected_action: str = EMAIL_CODE_SEND_ACTION, + ) -> None: normalized_token = token.strip() if token else "" if not normalized_token or len(normalized_token) > _MAX_TOKEN_LENGTH: raise TurnstileChallengeRejectedError @@ -74,7 +81,7 @@ class TurnstileService: 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): + if result.action != expected_action or not cls._is_allowed_hostname(result.hostname, allowed_hostnames): raise TurnstileChallengeRejectedError @staticmethod diff --git a/api/tests/unit_tests/configs/test_dify_config.py b/api/tests/unit_tests/configs/test_dify_config.py index f606b731e4a..85ae1054677 100644 --- a/api/tests/unit_tests/configs/test_dify_config.py +++ b/api/tests/unit_tests/configs/test_dify_config.py @@ -121,11 +121,19 @@ def test_turnstile_config_is_parsed() -> None: config = _make_config( TURNSTILE_SECRET_KEY=" test-secret ", TURNSTILE_ALLOWED_HOSTNAMES="dify.dev, Login.Example.COM. ", + TURNSTILE_EMAIL_CODE_VERIFY_REQUIRED="true", ) 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 + assert config.TURNSTILE_EMAIL_CODE_VERIFY_REQUIRED is True + + +def test_email_code_login_attempt_budget_is_parsed() -> None: + config = _make_config(EMAIL_CODE_LOGIN_MAX_ATTEMPTS="7") + + assert config.EMAIL_CODE_LOGIN_MAX_ATTEMPTS == 7 def test_plugin_remote_install_port_rejects_host_port_spec() -> None: diff --git a/api/tests/unit_tests/controllers/console/auth/test_email_verification.py b/api/tests/unit_tests/controllers/console/auth/test_email_verification.py index 0279936f635..573934239f5 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_email_verification.py +++ b/api/tests/unit_tests/controllers/console/auth/test_email_verification.py @@ -17,6 +17,7 @@ from pydantic import ValidationError from controllers.console.auth.error import ( EmailCodeError, + EmailCodeLoginServiceUnavailableError, InvalidEmailError, InvalidTokenError, TurnstileServiceUnavailableError, @@ -37,9 +38,16 @@ from controllers.console.error import ( WorkspacesLimitExceeded, ) from enums import DeploymentEdition +from services.email_code_login_challenge import ( + EmailCodeLoginChallengeResult, + EmailCodeLoginChallengeStatus, + EmailCodeLoginChallengeUnavailableError, +) from services.errors.account import AccountRegisterError from services.turnstile_service import TurnstileChallengeRejectedError, TurnstileUpstreamError +TEST_TOKEN = "00000000-0000-4000-8000-000000000001" + def encode_code(code: str) -> str: """Helper to encode verification code as Base64 for testing.""" @@ -52,7 +60,7 @@ def test_email_code_login_payload_rejects_invalid_timezone(): { "email": "newuser@example.com", "code": "123456", - "token": "token-123", + "token": TEST_TOKEN, "timezone": "", } ) @@ -61,6 +69,18 @@ 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 + assert "turnstile_token" in EmailCodeLoginPayload.model_fields + + +def test_email_code_login_code_schema_does_not_describe_plaintext_format(): + code_schema = EmailCodeLoginPayload.model_json_schema()["properties"]["code"] + + assert "pattern" not in code_schema + + +def test_email_code_login_payload_rejects_non_uuid_token(): + with pytest.raises(ValidationError): + EmailCodeLoginPayload.model_validate({"email": "user@example.com", "code": "123456", "token": "not-a-uuid"}) class TestEmailCodeLoginSendEmailApi: @@ -379,9 +399,146 @@ class TestEmailCodeLoginApi: token_pair.csrf_token = "csrf_token" return token_pair + @pytest.mark.parametrize("code", ["12345", "1234567", "abcdef", "١٢٣٤٥٦"]) @patch("controllers.console.wraps.db") - @patch("controllers.console.auth.login.AccountService.get_email_code_login_data") - @patch("controllers.console.auth.login.AccountService.revoke_email_code_login_token") + @patch("controllers.console.auth.login.AccountService.verify_email_code_login_challenge") + def test_rejects_malformed_code_after_wire_decode( + self, + mock_verify_challenge, + mock_db, + app: Flask, + code: str, + ): + with ( + app.test_request_context( + "/email-code-login/validity", + method="POST", + json={"email": "test@example.com", "code": encode_code(code), "token": TEST_TOKEN}, + ), + pytest.raises(EmailCodeError), + ): + EmailCodeLoginApi().post() + + mock_verify_challenge.assert_not_called() + + @patch("controllers.console.wraps.db") + @patch("controllers.console.auth.login.AccountService.verify_email_code_login_challenge") + @patch("controllers.console.auth.login.TurnstileService.verify") + def test_cloud_verify_uses_separate_turnstile_action_when_required( + self, + mock_turnstile_verify, + mock_verify_challenge, + mock_db, + app: Flask, + ): + mock_verify_challenge.return_value = EmailCodeLoginChallengeResult( + status=EmailCodeLoginChallengeStatus.INVALID_TOKEN + ) + + with ( + patch("controllers.console.auth.login.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), + patch("controllers.console.auth.login.dify_config.TURNSTILE_EMAIL_CODE_VERIFY_REQUIRED", True), + app.test_request_context( + "/email-code-login/validity", + method="POST", + json={ + "email": "test@example.com", + "code": encode_code("123456"), + "token": TEST_TOKEN, + "turnstile_token": "verify-challenge-token", + }, + headers={"CF-Connecting-IP": "203.0.113.8"}, + ), + pytest.raises(InvalidTokenError), + ): + EmailCodeLoginApi().post() + + mock_turnstile_verify.assert_called_once_with( + token="verify-challenge-token", + remote_ip="203.0.113.8", + expected_action="signin_code_verify", + ) + + @patch("controllers.console.wraps.db") + @patch("controllers.console.auth.login.AccountService.verify_email_code_login_challenge") + @patch( + "controllers.console.auth.login.TurnstileService.verify", + side_effect=TurnstileChallengeRejectedError, + ) + def test_cloud_verify_rejects_missing_turnstile_before_consuming_code( + self, + mock_turnstile_verify, + mock_verify_challenge, + mock_db, + app: Flask, + ): + with ( + patch("controllers.console.auth.login.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), + patch("controllers.console.auth.login.dify_config.TURNSTILE_EMAIL_CODE_VERIFY_REQUIRED", True), + app.test_request_context( + "/email-code-login/validity", + method="POST", + json={"email": "test@example.com", "code": encode_code("123456"), "token": TEST_TOKEN}, + ), + pytest.raises(TurnstileVerificationFailedError), + ): + EmailCodeLoginApi().post() + + mock_turnstile_verify.assert_called_once() + mock_verify_challenge.assert_not_called() + + @patch("controllers.console.wraps.db") + @patch("controllers.console.auth.login.AccountService.verify_email_code_login_challenge") + @patch("controllers.console.auth.login.TurnstileService.verify") + def test_cloud_verify_flag_off_allows_legacy_client_without_turnstile( + self, + mock_turnstile_verify, + mock_verify_challenge, + mock_db, + app: Flask, + ): + mock_verify_challenge.return_value = EmailCodeLoginChallengeResult( + status=EmailCodeLoginChallengeStatus.INVALID_TOKEN + ) + + with ( + patch("controllers.console.auth.login.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), + patch("controllers.console.auth.login.dify_config.TURNSTILE_EMAIL_CODE_VERIFY_REQUIRED", False), + app.test_request_context( + "/email-code-login/validity", + method="POST", + json={"email": "test@example.com", "code": encode_code("123456"), "token": TEST_TOKEN}, + ), + pytest.raises(InvalidTokenError), + ): + EmailCodeLoginApi().post() + + mock_turnstile_verify.assert_not_called() + mock_verify_challenge.assert_called_once() + + @patch("controllers.console.wraps.db") + @patch( + "controllers.console.auth.login.AccountService.verify_email_code_login_challenge", + side_effect=EmailCodeLoginChallengeUnavailableError, + ) + def test_verify_maps_redis_failure_to_service_unavailable( + self, + mock_verify_challenge, + mock_db, + app: Flask, + ): + with ( + app.test_request_context( + "/email-code-login/validity", + method="POST", + json={"email": "test@example.com", "code": encode_code("123456"), "token": TEST_TOKEN}, + ), + pytest.raises(EmailCodeLoginServiceUnavailableError), + ): + EmailCodeLoginApi().post() + + @patch("controllers.console.wraps.db") + @patch("controllers.console.auth.login.AccountService.verify_email_code_login_challenge") @patch("controllers.console.auth.login.AccountService.get_user_through_email") @patch("controllers.console.auth.login.TenantService.get_join_tenants") @patch("controllers.console.auth.login.AccountService.login") @@ -392,8 +549,7 @@ class TestEmailCodeLoginApi: mock_login, mock_get_tenants, mock_get_user, - mock_revoke_token, - mock_get_data, + mock_verify_challenge, mock_db, app: Flask, mock_account, @@ -408,7 +564,9 @@ class TestEmailCodeLoginApi: - User is logged in with token pair """ # Arrange - mock_get_data.return_value = {"email": "test@example.com", "code": "123456"} + mock_verify_challenge.return_value = EmailCodeLoginChallengeResult( + status=EmailCodeLoginChallengeStatus.VERIFIED + ) mock_get_user.return_value = mock_account mock_get_tenants.return_value = [MagicMock()] mock_login.return_value = mock_token_pair @@ -417,19 +575,18 @@ class TestEmailCodeLoginApi: with app.test_request_context( "/email-code-login/validity", method="POST", - json={"email": "test@example.com", "code": encode_code("123456"), "token": "valid_token"}, + json={"email": "test@example.com", "code": encode_code("123456"), "token": TEST_TOKEN}, ): api = EmailCodeLoginApi() response = api.post() # Assert assert response.json["result"] == "success" - mock_revoke_token.assert_called_once_with("valid_token") + mock_verify_challenge.assert_called_once_with(email="test@example.com", code="123456", token=TEST_TOKEN) mock_login.assert_called_once() @patch("controllers.console.wraps.db") - @patch("controllers.console.auth.login.AccountService.get_email_code_login_data") - @patch("controllers.console.auth.login.AccountService.revoke_email_code_login_token") + @patch("controllers.console.auth.login.AccountService.verify_email_code_login_challenge") @patch("controllers.console.auth.login.AccountService.get_user_through_email") @patch("controllers.console.auth.login.AccountService.create_account_and_tenant") @patch("controllers.console.auth.login.AccountService.login") @@ -440,8 +597,7 @@ class TestEmailCodeLoginApi: mock_login, mock_create_account, mock_get_user, - mock_revoke_token, - mock_get_data, + mock_verify_challenge, mock_db, app: Flask, mock_account, @@ -456,7 +612,9 @@ class TestEmailCodeLoginApi: - User is logged in after account creation """ # Arrange - mock_get_data.return_value = {"email": "newuser@example.com", "code": "123456"} + mock_verify_challenge.return_value = EmailCodeLoginChallengeResult( + status=EmailCodeLoginChallengeStatus.VERIFIED + ) mock_get_user.return_value = None mock_create_account.return_value = mock_account mock_login.return_value = mock_token_pair @@ -470,7 +628,7 @@ class TestEmailCodeLoginApi: json={ "email": "newuser@example.com", "code": encode_code("123456"), - "token": "valid_token", + "token": TEST_TOKEN, "language": "en-US", "timezone": "Asia/Shanghai", }, @@ -491,8 +649,8 @@ class TestEmailCodeLoginApi: ) @patch("controllers.console.wraps.db") - @patch("controllers.console.auth.login.AccountService.get_email_code_login_data") - def test_email_code_login_invalid_token(self, mock_get_data, mock_db, app: Flask): + @patch("controllers.console.auth.login.AccountService.verify_email_code_login_challenge") + def test_email_code_login_invalid_token(self, mock_verify_challenge, mock_db, app: Flask): """ Test email code login with invalid token. @@ -500,21 +658,23 @@ class TestEmailCodeLoginApi: - InvalidTokenError is raised for invalid/expired tokens """ # Arrange - mock_get_data.return_value = None + mock_verify_challenge.return_value = EmailCodeLoginChallengeResult( + status=EmailCodeLoginChallengeStatus.INVALID_TOKEN + ) # Act & Assert with app.test_request_context( "/email-code-login/validity", method="POST", - json={"email": "test@example.com", "code": encode_code("123456"), "token": "invalid_token"}, + json={"email": "test@example.com", "code": encode_code("123456"), "token": TEST_TOKEN}, ): api = EmailCodeLoginApi() with pytest.raises(InvalidTokenError): api.post() @patch("controllers.console.wraps.db") - @patch("controllers.console.auth.login.AccountService.get_email_code_login_data") - def test_email_code_login_email_mismatch(self, mock_get_data, mock_db, app: Flask): + @patch("controllers.console.auth.login.AccountService.verify_email_code_login_challenge") + def test_email_code_login_email_mismatch(self, mock_verify_challenge, mock_db, app: Flask): """ Test email code login with mismatched email. @@ -522,21 +682,23 @@ class TestEmailCodeLoginApi: - InvalidEmailError is raised when email doesn't match token """ # Arrange - mock_get_data.return_value = {"email": "original@example.com", "code": "123456"} + mock_verify_challenge.return_value = EmailCodeLoginChallengeResult( + status=EmailCodeLoginChallengeStatus.EMAIL_MISMATCH + ) # Act & Assert with app.test_request_context( "/email-code-login/validity", method="POST", - json={"email": "different@example.com", "code": encode_code("123456"), "token": "token"}, + json={"email": "different@example.com", "code": encode_code("123456"), "token": TEST_TOKEN}, ): api = EmailCodeLoginApi() with pytest.raises(InvalidEmailError): api.post() @patch("controllers.console.wraps.db") - @patch("controllers.console.auth.login.AccountService.get_email_code_login_data") - def test_email_code_login_wrong_code(self, mock_get_data, mock_db, app: Flask): + @patch("controllers.console.auth.login.AccountService.verify_email_code_login_challenge") + def test_email_code_login_wrong_code(self, mock_verify_challenge, mock_db, app: Flask): """ Test email code login with incorrect code. @@ -544,21 +706,23 @@ class TestEmailCodeLoginApi: - EmailCodeError is raised for wrong verification code """ # Arrange - mock_get_data.return_value = {"email": "test@example.com", "code": "123456"} + mock_verify_challenge.return_value = EmailCodeLoginChallengeResult( + status=EmailCodeLoginChallengeStatus.INVALID_CODE, + remaining_attempts=4, + ) # Act & Assert with app.test_request_context( "/email-code-login/validity", method="POST", - json={"email": "test@example.com", "code": encode_code("wrong_code"), "token": "token"}, + json={"email": "test@example.com", "code": encode_code("654321"), "token": TEST_TOKEN}, ): api = EmailCodeLoginApi() with pytest.raises(EmailCodeError): api.post() @patch("controllers.console.wraps.db") - @patch("controllers.console.auth.login.AccountService.get_email_code_login_data") - @patch("controllers.console.auth.login.AccountService.revoke_email_code_login_token") + @patch("controllers.console.auth.login.AccountService.verify_email_code_login_challenge") @patch("controllers.console.auth.login.AccountService.get_user_through_email") @patch("controllers.console.auth.login.TenantService.get_join_tenants") @patch("controllers.console.auth.login.FeatureService.is_workspace_creation_allowed") @@ -567,8 +731,7 @@ class TestEmailCodeLoginApi: mock_is_workspace_creation_allowed, mock_get_tenants, mock_get_user, - mock_revoke_token, - mock_get_data, + mock_verify_challenge, mock_db, app: Flask, mock_account, @@ -581,7 +744,9 @@ class TestEmailCodeLoginApi: - User is added as owner of new workspace """ # Arrange - mock_get_data.return_value = {"email": "test@example.com", "code": "123456"} + mock_verify_challenge.return_value = EmailCodeLoginChallengeResult( + status=EmailCodeLoginChallengeStatus.VERIFIED + ) mock_get_user.return_value = mock_account mock_get_tenants.return_value = [] mock_is_workspace_creation_allowed.return_value = True @@ -590,15 +755,14 @@ class TestEmailCodeLoginApi: with app.test_request_context( "/email-code-login/validity", method="POST", - json={"email": "test@example.com", "code": "123456", "token": "token"}, + json={"email": "test@example.com", "code": encode_code("123456"), "token": TEST_TOKEN}, ): api = EmailCodeLoginApi() # This would complete the flow, but we're testing workspace creation logic # In real implementation, TenantService.create_tenant would be called @patch("controllers.console.wraps.db") - @patch("controllers.console.auth.login.AccountService.get_email_code_login_data") - @patch("controllers.console.auth.login.AccountService.revoke_email_code_login_token") + @patch("controllers.console.auth.login.AccountService.verify_email_code_login_challenge") @patch("controllers.console.auth.login.AccountService.get_user_through_email") @patch("controllers.console.auth.login.TenantService.get_join_tenants") @patch("controllers.console.auth.login.FeatureService.get_license") @@ -609,8 +773,7 @@ class TestEmailCodeLoginApi: mock_get_license, mock_get_tenants, mock_get_user, - mock_revoke_token, - mock_get_data, + mock_verify_challenge, mock_db, app: Flask, mock_account, @@ -622,7 +785,9 @@ class TestEmailCodeLoginApi: - WorkspacesLimitExceeded is raised when limit reached """ # Arrange - mock_get_data.return_value = {"email": "test@example.com", "code": "123456"} + mock_verify_challenge.return_value = EmailCodeLoginChallengeResult( + status=EmailCodeLoginChallengeStatus.VERIFIED + ) mock_get_user.return_value = mock_account mock_get_tenants.return_value = [] mock_get_license.return_value.workspaces.is_available.return_value = False @@ -632,15 +797,14 @@ class TestEmailCodeLoginApi: with app.test_request_context( "/email-code-login/validity", method="POST", - json={"email": "test@example.com", "code": encode_code("123456"), "token": "token"}, + json={"email": "test@example.com", "code": encode_code("123456"), "token": TEST_TOKEN}, ): api = EmailCodeLoginApi() with pytest.raises(WorkspacesLimitExceeded): api.post() @patch("controllers.console.wraps.db") - @patch("controllers.console.auth.login.AccountService.get_email_code_login_data") - @patch("controllers.console.auth.login.AccountService.revoke_email_code_login_token") + @patch("controllers.console.auth.login.AccountService.verify_email_code_login_challenge") @patch("controllers.console.auth.login.AccountService.get_user_through_email") @patch("controllers.console.auth.login.TenantService.get_join_tenants") @patch("controllers.console.auth.login.FeatureService.is_workspace_creation_allowed") @@ -649,8 +813,7 @@ class TestEmailCodeLoginApi: mock_is_workspace_creation_allowed, mock_get_tenants, mock_get_user, - mock_revoke_token, - mock_get_data, + mock_verify_challenge, mock_db, app: Flask, mock_account, @@ -662,7 +825,9 @@ class TestEmailCodeLoginApi: - NotAllowedCreateWorkspace is raised when creation disabled """ # Arrange - mock_get_data.return_value = {"email": "test@example.com", "code": "123456"} + mock_verify_challenge.return_value = EmailCodeLoginChallengeResult( + status=EmailCodeLoginChallengeStatus.VERIFIED + ) mock_get_user.return_value = mock_account mock_get_tenants.return_value = [] mock_is_workspace_creation_allowed.return_value = False @@ -671,7 +836,7 @@ class TestEmailCodeLoginApi: with app.test_request_context( "/email-code-login/validity", method="POST", - json={"email": "test@example.com", "code": encode_code("123456"), "token": "token"}, + json={"email": "test@example.com", "code": encode_code("123456"), "token": TEST_TOKEN}, ): api = EmailCodeLoginApi() with pytest.raises(NotAllowedCreateWorkspace): diff --git a/api/tests/unit_tests/controllers/console/auth/test_login_logout.py b/api/tests/unit_tests/controllers/console/auth/test_login_logout.py index 970acd52cfa..a4da84d1c77 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_login_logout.py +++ b/api/tests/unit_tests/controllers/console/auth/test_login_logout.py @@ -30,9 +30,12 @@ from controllers.console.error import ( WorkspacesLimitExceeded, ) from enums import DeploymentEdition +from services.email_code_login_challenge import EmailCodeLoginChallengeResult, EmailCodeLoginChallengeStatus from services.entities.auth_entities import LoginFailureReason from services.errors.account import AccountLoginError, AccountPasswordError, SeatsLimitExceededError +TEST_TOKEN = "00000000-0000-4000-8000-000000000001" + def encode_password(password: str) -> str: """Helper to encode password as Base64 for testing.""" @@ -458,30 +461,29 @@ class TestLoginApi: mock_reset_rate_limit.assert_called_once_with("upper@example.com") @patch("controllers.console.wraps.db") - @patch("controllers.console.auth.login.AccountService.get_email_code_login_data") - @patch("controllers.console.auth.login.AccountService.revoke_email_code_login_token") + @patch("controllers.console.auth.login.AccountService.verify_email_code_login_challenge") @patch("controllers.console.auth.login._get_account_with_case_fallback") def test_email_code_login_logs_banned_account( self, mock_get_account: MagicMock, - mock_revoke_token: MagicMock, - mock_get_token_data: MagicMock, + mock_verify_challenge: MagicMock, mock_db: MagicMock, app: Flask, caplog: pytest.LogCaptureFixture, ): - mock_get_token_data.return_value = {"email": "User@Example.com", "code": "123456"} + mock_verify_challenge.return_value = EmailCodeLoginChallengeResult( + status=EmailCodeLoginChallengeStatus.VERIFIED + ) mock_get_account.side_effect = Unauthorized("Account is banned.") with app.test_request_context( "/email-code-login/validity", method="POST", - json={"email": "User@Example.com", "code": encode_code("123456"), "token": "token-123"}, + json={"email": "User@Example.com", "code": encode_code("123456"), "token": TEST_TOKEN}, ): with pytest.raises(AccountBannedError): EmailCodeLoginApi().post() - mock_revoke_token.assert_called_once_with("token-123") warn_records = [ r for r in caplog.records if r.name == "controllers.console.auth.login" and r.levelno == logging.WARNING ] @@ -492,14 +494,12 @@ class TestLoginApi: @patch("controllers.console.wraps.db") @patch("controllers.console.auth.login.db") @patch("controllers.console.auth.login.AccountService.create_account_and_tenant") - @patch("controllers.console.auth.login.AccountService.get_email_code_login_data") - @patch("controllers.console.auth.login.AccountService.revoke_email_code_login_token") + @patch("controllers.console.auth.login.AccountService.verify_email_code_login_challenge") @patch("controllers.console.auth.login._get_account_with_case_fallback") def test_email_code_login_fails_when_seats_limit_exceeded( self, mock_get_account: MagicMock, - mock_revoke_token: MagicMock, - mock_get_token_data: MagicMock, + mock_verify_challenge: MagicMock, mock_create_account: MagicMock, mock_login_db: MagicMock, mock_db: MagicMock, @@ -513,7 +513,9 @@ class TestLoginApi: - the service-layer SeatsLimitExceededError is translated to the SeatsLimitExceeded HTTP error """ # Arrange: valid token, no existing account -> account-creation path - mock_get_token_data.return_value = {"email": "User@Example.com", "code": "123456"} + mock_verify_challenge.return_value = EmailCodeLoginChallengeResult( + status=EmailCodeLoginChallengeStatus.VERIFIED + ) mock_get_account.return_value = None mock_create_account.side_effect = SeatsLimitExceededError("licensed seats limit exceeded") @@ -521,7 +523,7 @@ class TestLoginApi: with app.test_request_context( "/email-code-login/validity", method="POST", - json={"email": "User@Example.com", "code": encode_code("123456"), "token": "token-123"}, + json={"email": "User@Example.com", "code": encode_code("123456"), "token": TEST_TOKEN}, ): with pytest.raises(SeatsLimitExceeded): EmailCodeLoginApi().post() diff --git a/api/tests/unit_tests/services/test_email_code_login_challenge.py b/api/tests/unit_tests/services/test_email_code_login_challenge.py new file mode 100644 index 00000000000..761c1bffe66 --- /dev/null +++ b/api/tests/unit_tests/services/test_email_code_login_challenge.py @@ -0,0 +1,174 @@ +import json +from collections.abc import Iterator +from unittest.mock import MagicMock, patch + +import pytest +from redis.exceptions import ConnectionError + +from services.email_code_login_challenge import ( + EmailCodeLoginChallengeStatus, + EmailCodeLoginChallengeStore, + EmailCodeLoginChallengeUnavailableError, +) + +TOKEN = "00000000-0000-4000-8000-000000000001" + + +@pytest.fixture +def challenge_redis() -> Iterator[MagicMock]: + with patch("services.email_code_login_challenge.redis_client") as mock_redis: + yield mock_redis + + +def test_create_stores_only_one_per_email_v2_challenge( + challenge_redis: MagicMock, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr("services.email_code_login_challenge.dify_config.EMAIL_CODE_LOGIN_MAX_ATTEMPTS", 5) + monkeypatch.setattr("services.email_code_login_challenge.dify_config.EMAIL_CODE_LOGIN_TOKEN_EXPIRY_MINUTES", 5) + + with patch("services.email_code_login_challenge.uuid.uuid4", return_value=TOKEN): + token = EmailCodeLoginChallengeStore.create( + email="User@Example.com", + code="123456", + account_id="account-id", + ) + + assert token == TOKEN + challenge_key, ttl, serialized_payload = challenge_redis.setex.call_args.args + assert challenge_key == EmailCodeLoginChallengeStore._challenge_key("user@example.com") + assert ttl == 300 + assert json.loads(serialized_payload) == { + "account_id": "account-id", + "email": "user@example.com", + "token_type": "email_code_login", + "code": "123456", + "remaining_attempts": 5, + "challenge_version": 2, + "state": "active", + "token": TOKEN, + } + assert challenge_key != f"email_code_login:token:{TOKEN}" + challenge_redis.set.assert_not_called() + challenge_redis.delete.assert_not_called() + + +def test_verify_current_challenge_decrements_budget_without_refreshing_ttl(challenge_redis: MagicMock) -> None: + challenge_redis.eval.return_value = [3, 4] + + result = EmailCodeLoginChallengeStore.verify( + email="User@Example.com", + code="654321", + token=TOKEN, + ) + + assert result.status is EmailCodeLoginChallengeStatus.INVALID_CODE + assert result.remaining_attempts == 4 + eval_args = challenge_redis.eval.call_args.args + assert eval_args[1] == 1 + assert eval_args[2] == EmailCodeLoginChallengeStore._challenge_key("user@example.com") + assert eval_args[-5:] == ("email_code_login", TOKEN, "user@example.com", "654321", 2) + challenge_redis.set.assert_not_called() + challenge_redis.expire.assert_not_called() + + +@pytest.mark.parametrize( + ("lua_response", "expected_status"), + [ + ([1, -1], EmailCodeLoginChallengeStatus.INVALID_TOKEN), + ([2, -1], EmailCodeLoginChallengeStatus.EMAIL_MISMATCH), + ([4, -1], EmailCodeLoginChallengeStatus.VERIFIED), + ([6, 0], EmailCodeLoginChallengeStatus.EXHAUSTED), + ([8, -1], EmailCodeLoginChallengeStatus.INVALID_TOKEN), + ], +) +def test_verify_maps_v2_lua_result( + challenge_redis: MagicMock, + lua_response: list[int], + expected_status: EmailCodeLoginChallengeStatus, +) -> None: + challenge_redis.eval.return_value = lua_response + + result = EmailCodeLoginChallengeStore.verify( + email="user@example.com", + code="123456", + token=TOKEN, + ) + + assert result.status is expected_status + challenge_redis.eval.assert_called_once() + + +def test_terminal_v2_challenge_blocks_pre_rollout_legacy_token_fallback(challenge_redis: MagicMock) -> None: + legacy_token = "00000000-0000-4000-8000-000000000002" + challenge_redis.eval.return_value = [8, -1] + + result = EmailCodeLoginChallengeStore.verify( + email="user@example.com", + code="111111", + token=legacy_token, + ) + + assert result.status is EmailCodeLoginChallengeStatus.INVALID_TOKEN + challenge_redis.eval.assert_called_once() + assert f"email_code_login:token:{legacy_token}" not in challenge_redis.eval.call_args.args + + +def test_verify_supports_unversioned_token_created_before_rollout(challenge_redis: MagicMock) -> None: + challenge_redis.eval.side_effect = [[0, -1], [4, -1]] + + result = EmailCodeLoginChallengeStore.verify( + email="user@example.com", + code="123456", + token=TOKEN, + ) + + assert result.status is EmailCodeLoginChallengeStatus.VERIFIED + assert challenge_redis.eval.call_count == 2 + legacy_args = challenge_redis.eval.call_args_list[1].args + assert legacy_args[2] == f"email_code_login:token:{TOKEN}" + assert legacy_args[-4:] == ("email_code_login", "user@example.com", "123456", 5) + + +def test_verify_rejects_versioned_payload_in_legacy_fallback(challenge_redis: MagicMock) -> None: + challenge_redis.eval.side_effect = [[0, -1], [7, -1]] + + result = EmailCodeLoginChallengeStore.verify( + email="user@example.com", + code="123456", + token=TOKEN, + ) + + assert result.status is EmailCodeLoginChallengeStatus.INVALID_TOKEN + + +def test_create_fails_closed_on_redis_error(challenge_redis: MagicMock) -> None: + challenge_redis.setex.side_effect = ConnectionError("redis unavailable") + + with pytest.raises(EmailCodeLoginChallengeUnavailableError): + EmailCodeLoginChallengeStore.create( + email="user@example.com", + code="123456", + account_id=None, + ) + + +def test_verify_fails_closed_on_redis_error(challenge_redis: MagicMock) -> None: + challenge_redis.eval.side_effect = ConnectionError("redis unavailable") + + with pytest.raises(EmailCodeLoginChallengeUnavailableError): + EmailCodeLoginChallengeStore.verify( + email="user@example.com", + code="123456", + token=TOKEN, + ) + + +def test_verify_fails_closed_on_unexpected_lua_response(challenge_redis: MagicMock) -> None: + challenge_redis.eval.return_value = None + + with pytest.raises(EmailCodeLoginChallengeUnavailableError): + EmailCodeLoginChallengeStore.verify( + email="user@example.com", + code="123456", + token=TOKEN, + ) diff --git a/api/tests/unit_tests/services/test_turnstile_service.py b/api/tests/unit_tests/services/test_turnstile_service.py index 795b914034f..5c2173b5eb8 100644 --- a/api/tests/unit_tests/services/test_turnstile_service.py +++ b/api/tests/unit_tests/services/test_turnstile_service.py @@ -5,6 +5,7 @@ import pytest from pydantic import SecretStr from services.turnstile_service import ( + EMAIL_CODE_VERIFY_ACTION, TurnstileChallengeRejectedError, TurnstileService, TurnstileUpstreamError, @@ -46,6 +47,19 @@ def test_verify_accepts_subdomain_and_forwards_remote_ip(monkeypatch: pytest.Mon ) +def test_verify_accepts_caller_scoped_action(monkeypatch: pytest.MonkeyPatch) -> None: + mock_response( + monkeypatch, + payload={"success": True, "action": EMAIL_CODE_VERIFY_ACTION, "hostname": "agent.dify.dev"}, + ) + + TurnstileService.verify( + token="verified-token", + remote_ip=None, + expected_action=EMAIL_CODE_VERIFY_ACTION, + ) + + @pytest.mark.parametrize("token", [None, "", " ", "x" * 2049]) def test_verify_rejects_missing_or_oversized_token(monkeypatch: pytest.MonkeyPatch, token: str | None) -> None: post = MagicMock() diff --git a/docker/envs/core-services/api.env.example b/docker/envs/core-services/api.env.example index 82962444c52..9344d5a1a6b 100644 --- a/docker/envs/core-services/api.env.example +++ b/docker/envs/core-services/api.env.example @@ -20,3 +20,4 @@ KNOWLEDGE_FS_TIMEOUT_SECONDS=10 # Cloudflare Turnstile server-side verification for Dify Cloud sign-in TURNSTILE_SECRET_KEY= TURNSTILE_ALLOWED_HOSTNAMES= +TURNSTILE_EMAIL_CODE_VERIFY_REQUIRED=false diff --git a/docker/envs/core-services/shared.env.example b/docker/envs/core-services/shared.env.example index f4c1211e6b4..3cd595d9ea2 100644 --- a/docker/envs/core-services/shared.env.example +++ b/docker/envs/core-services/shared.env.example @@ -19,6 +19,8 @@ FILES_ACCESS_TIMEOUT=300 # System Features MARKETPLACE_ENABLED=true ENABLE_EMAIL_CODE_LOGIN=false +EMAIL_CODE_LOGIN_TOKEN_EXPIRY_MINUTES=5 +EMAIL_CODE_LOGIN_MAX_ATTEMPTS=5 ENABLE_EMAIL_PASSWORD_LOGIN=true ENABLE_SOCIAL_OAUTH_LOGIN=false # Remove `collaboration` from COMPOSE_PROFILES to stop the dedicated websocket service. diff --git a/packages/contracts/generated/api/console/email-code-login/types.gen.ts b/packages/contracts/generated/api/console/email-code-login/types.gen.ts index 293fe010f7b..a07b403d8d2 100644 --- a/packages/contracts/generated/api/console/email-code-login/types.gen.ts +++ b/packages/contracts/generated/api/console/email-code-login/types.gen.ts @@ -21,6 +21,7 @@ export type EmailCodeLoginPayload = { language?: string | null timezone?: string | null token: string + turnstile_token?: string | null } export type SimpleResultResponse = { diff --git a/packages/contracts/generated/api/console/email-code-login/zod.gen.ts b/packages/contracts/generated/api/console/email-code-login/zod.gen.ts index 14c69a9076a..bd2af92db1b 100644 --- a/packages/contracts/generated/api/console/email-code-login/zod.gen.ts +++ b/packages/contracts/generated/api/console/email-code-login/zod.gen.ts @@ -8,7 +8,7 @@ import * as z from 'zod' export const zEmailCodeSendPayload = z.object({ email: z.string(), language: z.string().nullish(), - turnstile_token: z.string().nullish(), + turnstile_token: z.string().max(2048).nullish(), }) /** @@ -27,7 +27,8 @@ export const zEmailCodeLoginPayload = z.object({ email: z.string(), language: z.string().nullish(), timezone: z.string().nullish(), - token: z.string(), + token: z.uuid(), + turnstile_token: z.string().max(2048).nullish(), }) /** diff --git a/web/app/signin/check-code/__tests__/page.spec.tsx b/web/app/signin/check-code/__tests__/page.spec.tsx index 97a7d1bf6d8..2700fd73629 100644 --- a/web/app/signin/check-code/__tests__/page.spec.tsx +++ b/web/app/signin/check-code/__tests__/page.spec.tsx @@ -1,7 +1,7 @@ import type { GetAccountProfileResponse } from '@dify/contracts/api/console/account/types.gen' import type { DeploymentEdition } from '@dify/contracts/api/console/system-features/types.gen' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { act, render, screen, waitFor } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { userProfileQueryOptions } from '@/features/account-profile/client' import { emailLoginWithCode, sendEMailLoginCode } from '@/service/common' @@ -26,6 +26,7 @@ type ScriptProps = { } type TurnstileOptions = { + action: string callback: (token: string) => void } @@ -36,6 +37,7 @@ const turnstileMocks = vi.hoisted(() => ({ scriptProps: undefined as ScriptProps | undefined, siteKey: '', })) +const turnstileWidgets = new Map() vi.mock('@/app/components/base/amplitude', () => ({ trackEvent: vi.fn(), @@ -100,6 +102,14 @@ function createQueryClient() { return queryClient } +function createDeferred() { + let resolve: (value: T) => void = () => {} + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise + }) + return { promise, resolve } +} + function installTurnstileApi() { Object.defineProperty(window, 'turnstile', { configurable: true, @@ -128,6 +138,9 @@ const accountProfile: GetAccountProfileResponse = { describe('CheckCode', () => { beforeEach(() => { vi.clearAllMocks() + vi.mocked(emailLoginWithCode).mockReset().mockResolvedValue({ result: 'success' }) + vi.mocked(sendEMailLoginCode).mockReset() + turnstileWidgets.clear() navigationMocks.searchParams = new URLSearchParams({ email: 'user@example.com', redirect_url: '/apps', @@ -142,17 +155,20 @@ describe('CheckCode', () => { const verifyButton = document.createElement('button') verifyButton.type = 'button' verifyButton.dataset.widgetId = widgetId - verifyButton.textContent = 'verify-turnstile' - verifyButton.addEventListener('click', () => options.callback('fresh-turnstile-token')) + verifyButton.textContent = `verify-turnstile-${options.action}` + verifyButton.addEventListener('click', () => + options.callback(`${options.action}-token-${turnstileMocks.render.mock.calls.length}`), + ) container.appendChild(verifyButton) + turnstileWidgets.set(widgetId, verifyButton) return widgetId }, ) turnstileMocks.remove.mockImplementation((widgetId: string) => { - document.querySelector(`[data-widget-id="${widgetId}"]`)?.remove() + turnstileWidgets.get(widgetId)?.remove() + turnstileWidgets.delete(widgetId) }) installTurnstileApi() - vi.mocked(emailLoginWithCode).mockResolvedValue({ result: 'success' }) }) afterEach(() => { @@ -191,7 +207,192 @@ describe('CheckCode', () => { expect(navigationMocks.back).toHaveBeenCalledOnce() }) - it('uses a fresh Turnstile token for each Cloud resend', async () => { + it('rejects verification codes that are not exactly six digits', async () => { + const user = userEvent.setup() + const queryClient = createQueryClient() + render( + + + , + ) + + fireEvent.change(screen.getByLabelText('login.checkCode.verificationCode'), { + target: { value: '1234567' }, + }) + await user.click(screen.getByRole('button', { name: 'login.checkCode.verify' })) + + expect(emailLoginWithCode).not.toHaveBeenCalled() + }) + + it('keeps Community verification independent of Turnstile', async () => { + const user = userEvent.setup() + const queryClient = createQueryClient() + vi.mocked(emailLoginWithCode).mockResolvedValue({ + code: 'invalid_code', + data: '', + message: 'Invalid code', + result: 'fail', + }) + render( + + + , + ) + + await user.type(screen.getByLabelText('login.checkCode.verificationCode'), '123456') + await user.click(screen.getByRole('button', { name: 'login.checkCode.verify' })) + + expect(emailLoginWithCode).toHaveBeenCalledWith({ + code: '123456', + email: 'user@example.com', + language: expect.any(String), + timezone: 'Asia/Singapore', + token: 'email-login-token', + }) + expect(turnstileMocks.render).not.toHaveBeenCalled() + }) + + it('does not resend while verification is in progress', async () => { + const user = userEvent.setup() + const queryClient = createQueryClient() + const verificationRequest = createDeferred>>() + vi.mocked(emailLoginWithCode).mockReturnValue(verificationRequest.promise) + render( + + + , + ) + + await user.type(screen.getByLabelText('login.checkCode.verificationCode'), '123456') + await user.click(screen.getByRole('button', { name: 'login.checkCode.verify' })) + await waitFor(() => { + expect(emailLoginWithCode).toHaveBeenCalledOnce() + }) + + const resendButton = screen.getByRole('button', { name: 'resend-code' }) + expect(resendButton).toBeDisabled() + resendButton.removeAttribute('disabled') + fireEvent.click(resendButton) + expect(sendEMailLoginCode).not.toHaveBeenCalled() + + act(() => { + verificationRequest.resolve({ + code: 'invalid_code', + data: '', + message: 'Invalid code', + result: 'fail', + }) + }) + await waitFor(() => { + expect(screen.getByRole('button', { name: 'login.checkCode.verify' })).toBeEnabled() + }) + }) + + it('does not verify while resend is in progress', async () => { + const user = userEvent.setup() + const queryClient = createQueryClient() + const resendRequest = createDeferred>>() + vi.mocked(sendEMailLoginCode).mockReturnValue(resendRequest.promise) + render( + + + , + ) + + await user.type(screen.getByLabelText('login.checkCode.verificationCode'), '123456') + await user.click(screen.getByRole('button', { name: 'resend-code' })) + await waitFor(() => { + expect(sendEMailLoginCode).toHaveBeenCalledOnce() + }) + + const verifyButton = screen.getByRole('button', { name: 'login.checkCode.verify' }) + expect(verifyButton).toBeDisabled() + const form = verifyButton.closest('form') + if (!form) throw new Error('Verification form is missing') + fireEvent.submit(form) + expect(emailLoginWithCode).not.toHaveBeenCalled() + + act(() => { + resendRequest.resolve({ data: '', result: 'fail' }) + }) + await waitFor(() => { + expect(verifyButton).toBeEnabled() + }) + }) + + it('requires a fresh verify-action Turnstile token after every Cloud login attempt', async () => { + const user = userEvent.setup() + turnstileMocks.deploymentEdition = 'CLOUD' + turnstileMocks.siteKey = 'cloud-site-key' + const queryClient = createQueryClient() + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.mocked(emailLoginWithCode) + .mockRejectedValueOnce(new Error('invalid verification code')) + .mockResolvedValueOnce({ + code: 'invalid_code', + data: '', + message: 'Invalid code', + result: 'fail', + }) + + render( + + + , + ) + + const codeInput = screen.getByLabelText('login.checkCode.verificationCode') + const verifyButton = screen.getByRole('button', { name: 'login.checkCode.verify' }) + expect(verifyButton).toBeDisabled() + + act(() => { + turnstileMocks.scriptProps?.onReady?.() + }) + expect(turnstileMocks.render).toHaveBeenLastCalledWith( + expect.any(HTMLElement), + expect.objectContaining({ action: 'signin_code_verify' }), + ) + await user.click( + await screen.findByRole('button', { name: 'verify-turnstile-signin_code_verify' }), + ) + expect(verifyButton).toBeEnabled() + + await user.type(codeInput, '123456') + await user.click(verifyButton) + + await waitFor(() => { + expect(emailLoginWithCode).toHaveBeenCalledWith( + expect.objectContaining({ + turnstile_token: 'signin_code_verify-token-1', + }), + ) + }) + expect(codeInput).toHaveValue('123456') + await waitFor(() => { + expect(verifyButton).toBeDisabled() + expect(turnstileMocks.remove).toHaveBeenCalledWith('widget-1') + expect(turnstileMocks.render).toHaveBeenCalledTimes(2) + }) + expect(turnstileMocks.render).toHaveBeenLastCalledWith( + expect.any(HTMLElement), + expect.objectContaining({ action: 'signin_code_verify' }), + ) + await user.click( + await screen.findByRole('button', { name: 'verify-turnstile-signin_code_verify' }), + ) + await user.click(verifyButton) + + await waitFor(() => { + expect(emailLoginWithCode).toHaveBeenLastCalledWith( + expect.objectContaining({ + turnstile_token: 'signin_code_verify-token-2', + }), + ) + }) + expect(codeInput).toHaveValue('123456') + }) + + it('keeps the Cloud resend challenge separate from the verify challenge', async () => { const user = userEvent.setup() turnstileMocks.deploymentEdition = 'CLOUD' turnstileMocks.siteKey = 'cloud-site-key' @@ -206,26 +407,44 @@ describe('CheckCode', () => { const resendButton = screen.getByRole('button', { name: 'resend-code' }) expect(resendButton).toBeEnabled() - expect(screen.queryByRole('button', { name: 'verify-turnstile' })).not.toBeInTheDocument() + act(() => { + turnstileMocks.scriptProps?.onReady?.() + }) + await user.click( + await screen.findByRole('button', { name: 'verify-turnstile-signin_code_verify' }), + ) + expect(screen.getByRole('button', { name: 'login.checkCode.verify' })).toBeEnabled() await user.click(resendButton) expect(resendButton).toBeDisabled() - act(() => { - turnstileMocks.scriptProps?.onReady?.() - }) - await user.click(await screen.findByRole('button', { name: 'verify-turnstile' })) + expect(screen.getByRole('button', { name: 'login.checkCode.verify' })).toBeDisabled() + expect(turnstileMocks.remove).toHaveBeenCalledWith('widget-1') + expect( + screen.queryByRole('button', { name: 'verify-turnstile-signin_code_verify' }), + ).not.toBeInTheDocument() + expect(turnstileMocks.render).toHaveBeenLastCalledWith( + expect.any(HTMLElement), + expect.objectContaining({ action: 'signin_code' }), + ) + await user.click(await screen.findByRole('button', { name: 'verify-turnstile-signin_code' })) await waitFor(() => { expect(sendEMailLoginCode).toHaveBeenCalledWith( 'user@example.com', expect.any(String), - 'fresh-turnstile-token', + 'signin_code-token-2', ) }) await waitFor(() => { - expect(screen.queryByRole('button', { name: 'verify-turnstile' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'verify-turnstile-signin_code' }), + ).not.toBeInTheDocument() }) + expect(turnstileMocks.render).toHaveBeenLastCalledWith( + expect.any(HTMLElement), + expect.objectContaining({ action: 'signin_code_verify' }), + ) }) it('keeps Turnstile script-error recovery available during a Cloud resend', async () => { @@ -265,13 +484,13 @@ describe('CheckCode', () => { act(() => { turnstileMocks.scriptProps?.onReady?.() }) - await user.click(await screen.findByRole('button', { name: 'verify-turnstile' })) + await user.click(await screen.findByRole('button', { name: 'verify-turnstile-signin_code' })) await waitFor(() => { expect(sendEMailLoginCode).toHaveBeenCalledWith( 'user@example.com', expect.any(String), - 'fresh-turnstile-token', + 'signin_code-token-1', ) }) }) @@ -288,7 +507,7 @@ describe('CheckCode', () => { , ) - expect(screen.queryByRole('button', { name: 'verify-turnstile' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /verify-turnstile-/ })).not.toBeInTheDocument() const resendButton = screen.getByRole('button', { name: 'resend-code' }) expect(resendButton).toBeEnabled() await user.click(resendButton) diff --git a/web/app/signin/check-code/page.tsx b/web/app/signin/check-code/page.tsx index a0e9cceee04..56e82f35fb1 100644 --- a/web/app/signin/check-code/page.tsx +++ b/web/app/signin/check-code/page.tsx @@ -37,6 +37,8 @@ export default function CheckCode() { const [code, setVerifyCode] = useState('') const [loading, setIsLoading] = useState(false) const [isResending, setIsResending] = useState(false) + const [verifyTurnstileToken, setVerifyTurnstileToken] = useState('') + const [verifyTurnstileGeneration, setVerifyTurnstileGeneration] = useState(0) const [showResendTurnstile, setShowResendTurnstile] = useState(false) const [countdownGeneration, setCountdownGeneration] = useState(0) const locale = useLocale() @@ -44,28 +46,34 @@ export default function CheckCode() { const codeInputRef = useRef(null) const turnstileSiteKey = TURNSTILE_SITE_KEY.trim() const isTurnstileRequired = systemFeatures.deployment_edition === 'CLOUD' - const shouldRenderResendTurnstile = - isTurnstileRequired && Boolean(turnstileSiteKey) && showResendTurnstile + const shouldRenderTurnstile = isTurnstileRequired && Boolean(turnstileSiteKey) const pageTitle = t(($) => $['checkCode.checkYourEmail'], { ns: 'login' }) useDocumentTitle(pageTitle) const verify = async () => { + if (loading || isResending || showResendTurnstile) return + + let shouldResetTurnstile = false try { if (!code.trim()) { toast.error(t(($) => $['checkCode.emptyCode'], { ns: 'login' })) return } - if (!/\d{6}/.test(code)) { + if (!/^\d{6}$/.test(code)) { toast.error(t(($) => $['checkCode.invalidCode'], { ns: 'login' })) return } + if (isTurnstileRequired && !verifyTurnstileToken) return + setIsLoading(true) + shouldResetTurnstile = isTurnstileRequired const ret = await emailLoginWithCode({ email, code: encryptVerificationCode(code), token, language, timezone: getBrowserTimezone(), + ...(isTurnstileRequired ? { turnstile_token: verifyTurnstileToken } : {}), }) if (ret.result === 'success') { // Track login success event @@ -87,6 +95,10 @@ export default function CheckCode() { console.error(error) } finally { setIsLoading(false) + if (shouldResetTurnstile) { + setVerifyTurnstileToken('') + setVerifyTurnstileGeneration((value) => value + 1) + } } } @@ -123,7 +135,10 @@ export default function CheckCode() { } const handleResend = () => { + if (loading || isResending) return + if (isTurnstileRequired) { + setVerifyTurnstileToken('') setShowResendTurnstile(true) return } @@ -165,31 +180,52 @@ export default function CheckCode() { t(($) => $['checkCode.verificationCodePlaceholder'], { ns: 'login' }) as string } /> + {shouldRenderTurnstile && ( + { + if (showResendTurnstile) { + void resendCode(turnstileToken) + return + } + setVerifyTurnstileToken(turnstileToken) + }} + onInvalidate={() => { + if (showResendTurnstile) { + setShowResendTurnstile(false) + return + } + setVerifyTurnstileToken('') + }} + onError={() => { + setVerifyTurnstileToken('') + }} + /> + )} - {shouldRenderResendTurnstile && ( - { - void resendCode(turnstileToken) - }} - onInvalidate={() => { - setShowResendTurnstile(false) - }} - /> - )} diff --git a/web/app/signin/components/__tests__/turnstile.spec.tsx b/web/app/signin/components/__tests__/turnstile.spec.tsx index 9f12706e729..efe5cff0d77 100644 --- a/web/app/signin/components/__tests__/turnstile.spec.tsx +++ b/web/app/signin/components/__tests__/turnstile.spec.tsx @@ -88,7 +88,12 @@ describe('Turnstile', () => { render( - + , ) @@ -101,6 +106,7 @@ describe('Turnstile', () => { const onError = vi.fn() render( { }) render( { diff --git a/web/app/signin/components/turnstile.tsx b/web/app/signin/components/turnstile.tsx index 618bf0d0bf2..b0d999078a2 100644 --- a/web/app/signin/components/turnstile.tsx +++ b/web/app/signin/components/turnstile.tsx @@ -26,13 +26,22 @@ type TurnstileApi = { const getTurnstileApi = () => (window as Window & { turnstile?: TurnstileApi }).turnstile type TurnstileProps = { + action: 'signin_code' | 'signin_code_verify' + resetKey?: number siteKey: string onVerify: (token: string) => void onInvalidate: () => void onError?: () => void } -export default function Turnstile({ siteKey, onVerify, onInvalidate, onError }: TurnstileProps) { +export default function Turnstile({ + action, + resetKey = 0, + siteKey, + onVerify, + onInvalidate, + onError, +}: TurnstileProps) { const { t } = useTranslation() const containerRef = useRef(null) const onVerifyRef = useRef(onVerify) @@ -68,7 +77,7 @@ export default function Turnstile({ siteKey, onVerify, onInvalidate, onError }: try { widgetId = turnstile.render(container, { sitekey: siteKey, - action: 'signin_code', + action, appearance: 'always', size: 'flexible', theme: 'auto', @@ -91,7 +100,7 @@ export default function Turnstile({ siteKey, onVerify, onInvalidate, onError }: if (!widgetId) return turnstile.remove(widgetId) } - }, [handleChallengeError, hasError, invalidate, isScriptReady, siteKey]) + }, [action, handleChallengeError, hasError, invalidate, isScriptReady, resetKey, siteKey]) const handleScriptReady = () => { if (getTurnstileApi()) { diff --git a/web/service/common.spec.ts b/web/service/common.spec.ts index 4c8f6027141..ef678cb69f9 100644 --- a/web/service/common.spec.ts +++ b/web/service/common.spec.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' -import { sendEMailLoginCode } from './common' +import { emailLoginWithCode, sendEMailLoginCode } from './common' const mocks = vi.hoisted(() => ({ post: vi.fn(), @@ -40,3 +40,31 @@ describe('sendEMailLoginCode', () => { }) }) }) + +describe('emailLoginWithCode', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('includes the verification-specific Turnstile token when provided', async () => { + await emailLoginWithCode({ + code: 'encrypted-code', + email: 'user@example.com', + language: 'en-US', + timezone: 'Asia/Singapore', + token: 'email-login-token', + turnstile_token: 'verify-turnstile-token', + }) + + expect(mocks.post).toHaveBeenCalledWith('/email-code-login/validity', { + body: { + code: 'encrypted-code', + email: 'user@example.com', + language: 'en-US', + timezone: 'Asia/Singapore', + token: 'email-login-token', + turnstile_token: 'verify-turnstile-token', + }, + }) + }) +}) diff --git a/web/service/common.ts b/web/service/common.ts index 2191ca8a605..ba4cec521ae 100644 --- a/web/service/common.ts +++ b/web/service/common.ts @@ -1,3 +1,4 @@ +import type { EmailCodeLoginPayload } from '@dify/contracts/api/console/email-code-login/types.gen' import type { PostWorkspacesInfoData, PostWorkspacesInfoResponse, @@ -233,13 +234,8 @@ export const sendEMailLoginCode = ( }, }) -export const emailLoginWithCode = (data: { - email: string - code: string - token: string - language: string - timezone?: string -}): Promise => post('/email-code-login/validity', { body: data }) +export const emailLoginWithCode = (data: EmailCodeLoginPayload): Promise => + post('/email-code-login/validity', { body: data }) export const sendResetPasswordCode = ( email: string,