fix(auth): harden email code login verification (#40960)

This commit is contained in:
zyssyz123
2026-08-19 06:57:47 +00:00
committed by GitHub
parent f1b48a93a9
commit 414f211cde
25 changed files with 1141 additions and 125 deletions
+4
View File
@@ -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
+7
View File
@@ -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
+4
View File
@@ -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,
+6
View File
@@ -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."
+60 -16
View File
@@ -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(
+2 -1
View File
@@ -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
+13 -2
View File
@@ -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")
+268
View File
@@ -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}"
+10 -3
View File
@@ -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
@@ -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:
@@ -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):
@@ -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()
@@ -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,
)
@@ -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()
@@ -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
@@ -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.
@@ -21,6 +21,7 @@ export type EmailCodeLoginPayload = {
language?: string | null
timezone?: string | null
token: string
turnstile_token?: string | null
}
export type SimpleResultResponse = {
@@ -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(),
})
/**
+235 -16
View File
@@ -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<string, HTMLElement>()
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: vi.fn(),
@@ -100,6 +102,14 @@ function createQueryClient() {
return queryClient
}
function createDeferred<T>() {
let resolve: (value: T) => void = () => {}
const promise = new Promise<T>((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(
<QueryClientProvider client={queryClient}>
<CheckCode />
</QueryClientProvider>,
)
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(
<QueryClientProvider client={queryClient}>
<CheckCode />
</QueryClientProvider>,
)
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<Awaited<ReturnType<typeof emailLoginWithCode>>>()
vi.mocked(emailLoginWithCode).mockReturnValue(verificationRequest.promise)
render(
<QueryClientProvider client={queryClient}>
<CheckCode />
</QueryClientProvider>,
)
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<Awaited<ReturnType<typeof sendEMailLoginCode>>>()
vi.mocked(sendEMailLoginCode).mockReturnValue(resendRequest.promise)
render(
<QueryClientProvider client={queryClient}>
<CheckCode />
</QueryClientProvider>,
)
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(
<QueryClientProvider client={queryClient}>
<CheckCode />
</QueryClientProvider>,
)
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', () => {
</QueryClientProvider>,
)
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)
+52 -16
View File
@@ -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<HTMLInputElement>(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 && (
<Turnstile
action={showResendTurnstile ? 'signin_code' : 'signin_code_verify'}
resetKey={verifyTurnstileGeneration}
siteKey={turnstileSiteKey}
onVerify={(turnstileToken) => {
if (showResendTurnstile) {
void resendCode(turnstileToken)
return
}
setVerifyTurnstileToken(turnstileToken)
}}
onInvalidate={() => {
if (showResendTurnstile) {
setShowResendTurnstile(false)
return
}
setVerifyTurnstileToken('')
}}
onError={() => {
setVerifyTurnstileToken('')
}}
/>
)}
<Button
type="submit"
loading={loading}
disabled={loading}
disabled={
loading ||
isResending ||
showResendTurnstile ||
(isTurnstileRequired && !verifyTurnstileToken)
}
className="my-3 w-full"
variant="primary"
>
{t(($) => $['checkCode.verify'], { ns: 'login' })}
</Button>
{shouldRenderResendTurnstile && (
<Turnstile
siteKey={turnstileSiteKey}
onVerify={(turnstileToken) => {
void resendCode(turnstileToken)
}}
onInvalidate={() => {
setShowResendTurnstile(false)
}}
/>
)}
<Countdown
key={countdownGeneration}
onResend={handleResend}
resendDisabled={
isResending || showResendTurnstile || (isTurnstileRequired && !turnstileSiteKey)
loading ||
isResending ||
showResendTurnstile ||
(isTurnstileRequired && !turnstileSiteKey)
}
restartOnResend={false}
/>
@@ -88,7 +88,12 @@ describe('Turnstile', () => {
render(
<StrictMode>
<Turnstile siteKey="site-key" onVerify={vi.fn()} onInvalidate={vi.fn()} />
<Turnstile
action="signin_code"
siteKey="site-key"
onVerify={vi.fn()}
onInvalidate={vi.fn()}
/>
</StrictMode>,
)
@@ -101,6 +106,7 @@ describe('Turnstile', () => {
const onError = vi.fn()
render(
<Turnstile
action="signin_code"
siteKey="site-key"
onVerify={vi.fn()}
onInvalidate={onInvalidate}
@@ -157,6 +163,7 @@ describe('Turnstile', () => {
})
render(
<Turnstile
action="signin_code"
siteKey="site-key"
onVerify={vi.fn()}
onInvalidate={onInvalidate}
@@ -94,6 +94,7 @@ export default function MailAndCodeAuth({ isInvite }: MailAndCodeAuthProps) {
{shouldRenderTurnstile && (
<Turnstile
key={turnstileGeneration}
action="signin_code"
siteKey={turnstileSiteKey}
onVerify={setTurnstileToken}
onInvalidate={() => {
+12 -3
View File
@@ -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<HTMLDivElement>(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()) {
+29 -1
View File
@@ -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',
},
})
})
})
+3 -7
View File
@@ -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<LoginResponse> => post<LoginResponse>('/email-code-login/validity', { body: data })
export const emailLoginWithCode = (data: EmailCodeLoginPayload): Promise<LoginResponse> =>
post<LoginResponse>('/email-code-login/validity', { body: data })
export const sendResetPasswordCode = (
email: string,