mirror of
https://github.com/langgenius/dify.git
synced 2026-08-29 03:45:08 +08:00
fix(auth): harden email code login verification (#40960)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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}"
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user