fix(api): handle suspended email domains (#41004)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
林玮 (Jade Lin)
2026-08-20 08:04:05 +00:00
committed by GitHub
parent 013b328555
commit a6ace29e1a
22 changed files with 589 additions and 78 deletions
+6 -1
View File
@@ -6,11 +6,14 @@ from constants.languages import supported_language
from controllers.common.schema import query_params_from_model, register_schema_models
from controllers.console import console_ns
from controllers.console.auth.error import InvitationAccountMismatchError as InvitationAccountMismatchHTTPError
from controllers.console.error import AccountInFreezeError, AlreadyActivateError
from controllers.console.error import AccountInFreezeError, AlreadyActivateError, EmailDomainSuspendedError
from extensions.ext_application_services import application_services
from libs.helper import EmailStr, dump_response, timezone
from libs.login import current_account_with_tenant
from libs.token import extract_access_token
from services.account_activation_service import (
EmailDomainSuspendedError as EmailDomainSuspendedRegistrationError,
)
from services.account_activation_service import (
FrozenAccountError,
InvalidInvitationError,
@@ -141,6 +144,8 @@ class ActivateApi(Resource):
raise AlreadyActivateError() from None
except InvitationAccountMismatchError:
raise InvitationAccountMismatchHTTPError() from None
except EmailDomainSuspendedRegistrationError:
raise EmailDomainSuspendedError() from None
except FrozenAccountError:
raise AccountInFreezeError() from None
+18 -8
View File
@@ -24,9 +24,15 @@ from libs.password import valid_password
from models import Account
from services.account_service import AccountService
from services.billing_service import BillingService
from services.errors.account import AccountRegisterError, SeatsLimitExceededError
from services.errors.account import (
AccountRegisterError,
SeatsLimitExceededError,
)
from services.errors.account import (
EmailDomainSuspendedError as EmailDomainSuspendedRegistrationError,
)
from ..error import AccountInFreezeError, EmailSendIpLimitError, SeatsLimitExceeded
from ..error import AccountInFreezeError, EmailDomainSuspendedError, EmailSendIpLimitError, SeatsLimitExceeded
from ..wraps import email_password_login_enabled, email_register_enabled, model_validate, setup_required
@@ -99,10 +105,12 @@ class EmailRegisterSendEmailApi(Resource):
if req_data.language is not None and req_data.language in languages:
language = req_data.language
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and BillingService.is_email_in_freeze(
normalized_email
):
raise AccountInFreezeError()
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
freeze_type = BillingService.get_email_freeze_type(normalized_email)
if freeze_type:
if freeze_type == "email_domain_suspended":
raise EmailDomainSuspendedError()
raise AccountInFreezeError()
account = AccountService.get_account_by_email_with_case_fallback(req_data.email, session=db.session())
token = AccountService.send_email_register_email(email=normalized_email, account=account, language=language)
@@ -217,5 +225,7 @@ class EmailRegisterResetApi(Resource):
)
except SeatsLimitExceededError:
raise SeatsLimitExceeded()
except AccountRegisterError:
raise AccountInFreezeError()
except EmailDomainSuspendedRegistrationError as exc:
raise EmailDomainSuspendedError() from exc
except AccountRegisterError as exc:
raise AccountInFreezeError() from exc
+29 -13
View File
@@ -34,6 +34,7 @@ from controllers.console.error import (
AccountBannedError,
AccountInFreezeError,
AccountNotFound,
EmailDomainSuspendedError,
EmailSendIpLimitError,
NotAllowedCreateWorkspace,
SeatsLimitExceeded,
@@ -74,6 +75,9 @@ from services.errors.account import (
RefreshTokenNotFoundError,
SeatsLimitExceededError,
)
from services.errors.account import (
EmailDomainSuspendedError as EmailDomainSuspendedRegistrationError,
)
from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError
from services.feature_service import FeatureService
from services.turnstile_service import (
@@ -149,11 +153,13 @@ class LoginApi(Resource):
request_email = req_data.email
normalized_email = request_email.lower()
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and BillingService.is_email_in_freeze(
normalized_email
):
_log_console_login_failure(email=normalized_email, reason=LoginFailureReason.ACCOUNT_IN_FREEZE)
raise AccountInFreezeError()
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
freeze_type = BillingService.get_email_freeze_type(normalized_email)
if freeze_type:
_log_console_login_failure(email=normalized_email, reason=LoginFailureReason.ACCOUNT_IN_FREEZE)
if freeze_type == "email_domain_suspended":
raise EmailDomainSuspendedError()
raise AccountInFreezeError()
is_login_error_rate_limit = AccountService.is_login_error_rate_limit(normalized_email)
if is_login_error_rate_limit:
@@ -255,8 +261,10 @@ class ResetPasswordSendEmailApi(Resource):
language = "en-US"
try:
account = _get_account_with_case_fallback(req_data.email)
except AccountRegisterError:
raise AccountInFreezeError()
except EmailDomainSuspendedRegistrationError as exc:
raise EmailDomainSuspendedError() from exc
except AccountRegisterError as exc:
raise AccountInFreezeError() from exc
token = AccountService.send_reset_password_email(
email=normalized_email,
@@ -297,8 +305,10 @@ class EmailCodeLoginSendEmailApi(Resource):
language = "en-US"
try:
account = _get_account_with_case_fallback(req_data.email)
except AccountRegisterError:
raise AccountInFreezeError()
except EmailDomainSuspendedRegistrationError as exc:
raise EmailDomainSuspendedError() from exc
except AccountRegisterError as exc:
raise AccountInFreezeError() from exc
if account is None:
if FeatureService.get_system_features().is_allow_register:
@@ -377,9 +387,12 @@ class EmailCodeLoginApi(Resource):
except Unauthorized as exc:
_log_console_login_failure(email=user_email, reason=LoginFailureReason.ACCOUNT_BANNED)
raise AccountBannedError() from exc
except AccountRegisterError:
except EmailDomainSuspendedRegistrationError as exc:
_log_console_login_failure(email=user_email, reason=LoginFailureReason.ACCOUNT_IN_FREEZE)
raise AccountInFreezeError()
raise EmailDomainSuspendedError() from exc
except AccountRegisterError as exc:
_log_console_login_failure(email=user_email, reason=LoginFailureReason.ACCOUNT_IN_FREEZE)
raise AccountInFreezeError() from exc
if account:
tenants = TenantService.get_join_tenants(account, session=db.session())
if not tenants:
@@ -405,9 +418,12 @@ class EmailCodeLoginApi(Resource):
raise NotAllowedCreateWorkspace()
except SeatsLimitExceededError:
raise SeatsLimitExceeded()
except AccountRegisterError:
except EmailDomainSuspendedRegistrationError as exc:
_log_console_login_failure(email=user_email, reason=LoginFailureReason.ACCOUNT_IN_FREEZE)
raise AccountInFreezeError()
raise EmailDomainSuspendedError() from exc
except AccountRegisterError as exc:
_log_console_login_failure(email=user_email, reason=LoginFailureReason.ACCOUNT_IN_FREEZE)
raise AccountInFreezeError() from exc
except WorkspacesLimitExceededError:
raise WorkspacesLimitExceeded()
token_pair = AccountService.login(account, session=db.session(), ip_address=ip_address)
+19 -12
View File
@@ -12,6 +12,7 @@ from configs import dify_config
from constants.languages import languages
from controllers.common.fields import RedirectResponse
from controllers.common.schema import query_params_from_model, register_response_schema_model, register_schema_models
from controllers.console.error import AccountInFreezeError, EmailDomainSuspendedError
from enums import DeploymentEdition
from extensions.ext_database import db
from libs.datetime_utils import naive_utc_now
@@ -26,7 +27,14 @@ from libs.token import (
from models import Account, AccountStatus
from services.account_service import AccountService, RegisterService, TenantService
from services.billing_service import BillingService
from services.errors.account import AccountNotFoundError, AccountRegisterError, SeatsLimitExceededError
from services.errors.account import (
AccountNotFoundError,
AccountRegisterError,
SeatsLimitExceededError,
)
from services.errors.account import (
EmailDomainSuspendedError as EmailDomainSuspendedRegistrationError,
)
from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkSpaceNotFoundError
from services.feature_service import FeatureService
@@ -249,8 +257,10 @@ class OAuthCallback(Resource):
)
except SeatsLimitExceededError:
return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message=Licensed seats limit exceeded.")
except AccountRegisterError as e:
return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message={e.description}")
except EmailDomainSuspendedRegistrationError:
return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message={EmailDomainSuspendedError.description}")
except AccountRegisterError as exc:
return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message={exc.description}")
# Check account status
if account.status == AccountStatus.BANNED:
@@ -309,15 +319,12 @@ def _generate_account(
normalized_email = user_info.email.lower()
oauth_new_user = True
if not FeatureService.get_system_features().is_allow_register:
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and BillingService.is_email_in_freeze(
normalized_email
):
raise AccountRegisterError(
description=(
"This email account has been deleted within the past "
"30 days and is temporarily unavailable for new account registration"
)
)
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
freeze_type = BillingService.get_email_freeze_type(normalized_email)
if freeze_type:
if freeze_type == "email_domain_suspended":
raise EmailDomainSuspendedRegistrationError()
raise AccountRegisterError(description=AccountInFreezeError.description or "")
raise AccountRegisterError(description=("Invalid email or password"))
account_name = user_info.name or "Dify"
interface_language = _preferred_interface_language(language)
+7 -1
View File
@@ -92,11 +92,17 @@ class AccountInFreezeError(BaseHTTPException):
error_code = "account_in_freeze"
code = 400
description = (
"This email account has been deleted within the past 30 days"
"This email account has been deleted within the past 30 days "
"and is temporarily unavailable for new account registration."
)
class EmailDomainSuspendedError(BaseHTTPException):
error_code = "email_domain_suspended"
code = 400
description = "This email domain has been suspended."
class EducationVerifyLimitError(BaseHTTPException):
error_code = "education_verify_limit"
description = "Rate limit exceeded"
+14 -3
View File
@@ -29,7 +29,12 @@ from controllers.console.auth.error import (
InvalidEmailError,
InvalidTokenError,
)
from controllers.console.error import AccountInFreezeError, AccountNotFound, EmailSendIpLimitError
from controllers.console.error import (
AccountInFreezeError,
AccountNotFound,
EmailDomainSuspendedError,
EmailSendIpLimitError,
)
from controllers.console.flask_admission import console_account_admission
from controllers.console.workspace.error import (
AccountAlreadyInitedError,
@@ -769,7 +774,10 @@ class ChangeEmailResetApi(Resource):
args = ChangeEmailResetPayload.model_validate(payload)
normalized_new_email = args.new_email.lower()
if AccountService.is_account_in_freeze(normalized_new_email):
freeze_type = AccountService.get_account_freeze_type(normalized_new_email)
if freeze_type:
if freeze_type == "email_domain_suspended":
raise EmailDomainSuspendedError()
raise AccountInFreezeError()
if not AccountService.check_email_unique(normalized_new_email, session=db.session()):
@@ -816,7 +824,10 @@ class CheckEmailUnique(Resource):
payload = console_ns.payload or {}
args = CheckEmailUniquePayload.model_validate(payload)
normalized_email = args.email.lower()
if AccountService.is_account_in_freeze(normalized_email):
freeze_type = AccountService.get_account_freeze_type(normalized_email)
if freeze_type:
if freeze_type == "email_domain_suspended":
raise EmailDomainSuspendedError()
raise AccountInFreezeError()
if not AccountService.check_email_unique(normalized_email, session=db.session()):
raise EmailAlreadyInUseError()
+4 -2
View File
@@ -54,8 +54,10 @@ class BillingAccountActivationEligibility(AccountActivationEligibility):
self._enabled = enabled
@override
def is_frozen(self, email: str) -> bool:
return self._enabled and BillingService.is_email_in_freeze(email)
def get_freeze_type(self, email: str) -> str | None:
if not self._enabled:
return None
return BillingService.get_email_freeze_type(email)
class BillingWorkspaceMembershipCache(WorkspaceMembershipCache):
+9 -2
View File
@@ -41,7 +41,7 @@ class WorkspaceInvitePolicy(Protocol):
class AccountActivationEligibility(Protocol):
def is_frozen(self, email: str) -> bool: ...
def get_freeze_type(self, email: str) -> str | None: ...
class WorkspaceMembershipCache(Protocol):
@@ -60,6 +60,10 @@ class FrozenAccountError(Exception):
"""The invited account is temporarily ineligible for activation."""
class EmailDomainSuspendedError(Exception):
"""The invited account uses a suspended email domain."""
class AccountActivationService:
def __init__(
self,
@@ -101,7 +105,10 @@ class AccountActivationService:
if authenticated_account_id is not None and authenticated_account_id != invitation.account_id:
raise InvitationAccountMismatchError
if self._eligibility.is_frozen(invitation.account_email):
freeze_type = self._eligibility.get_freeze_type(invitation.account_email)
if freeze_type == "email_domain_suspended":
raise EmailDomainSuspendedError
if freeze_type:
raise FrozenAccountError
setup = self._resolve_setup(invitation, command)
+14 -3
View File
@@ -67,6 +67,7 @@ from services.errors.account import (
AccountRegisterError,
CannotOperateSelfError,
CurrentPasswordIncorrectError,
EmailDomainSuspendedError,
InvalidActionError,
LinkAccountIntegrateError,
MemberNotInTenantError,
@@ -470,6 +471,9 @@ class AccountService:
raise SeatsLimitExceededError("licensed seats limit exceeded")
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and BillingService.is_email_in_freeze(email):
freeze_type = BillingService.get_email_freeze_type(email) or "freeze"
if freeze_type == "email_domain_suspended":
raise EmailDomainSuspendedError()
raise AccountRegisterError(
description=(
"This email account has been deleted within the past "
@@ -1070,6 +1074,9 @@ class AccountService:
@classmethod
def get_user_through_email(cls, email: str, *, session: Session):
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and BillingService.is_email_in_freeze(email):
freeze_type = BillingService.get_email_freeze_type(email) or "freeze"
if freeze_type == "email_domain_suspended":
raise EmailDomainSuspendedError()
raise AccountRegisterError(
description=(
"This email account has been deleted within the past "
@@ -1088,9 +1095,13 @@ class AccountService:
@classmethod
def is_account_in_freeze(cls, email: str) -> bool:
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and BillingService.is_email_in_freeze(email):
return True
return False
return cls.get_account_freeze_type(email) is not None
@classmethod
def get_account_freeze_type(cls, email: str):
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD:
return None
return BillingService.get_email_freeze_type(email)
@staticmethod
@redis_fallback(default_return=None)
+19 -3
View File
@@ -28,6 +28,9 @@ _http_client: httpx.Client = get_pooled_http_client(
)
EmailFreezeType = Literal["freeze", "email_domain_suspended"]
class SubscriptionPlan(TypedDict):
"""Tenant subscriptionplan information."""
@@ -479,13 +482,26 @@ class BillingService:
return cls._send_request("DELETE", "/account", params=params)
@classmethod
def is_email_in_freeze(cls, email: str) -> bool:
def get_email_freeze_type(cls, email: str) -> EmailFreezeType | None:
params = {"email": email}
try:
response = cls._send_request("GET", "/account/in-freeze", params=params)
return bool(response.get("data", False))
if not response.get("data", False):
return None
freeze_type = response.get("freeze_type") or response.get("freezeType")
if freeze_type in ("freeze", "email_domain_suspended"):
return freeze_type
# Keep compatibility with older billing services that only return
# the boolean `data` field.
return "freeze"
except Exception:
return False
return None
@classmethod
def is_email_in_freeze(cls, email: str) -> bool:
return cls.get_email_freeze_type(email) is not None
@classmethod
def update_account_deletion_feedback(cls, email: str, feedback: str):
+5
View File
@@ -9,6 +9,11 @@ class AccountRegisterError(BaseServiceError):
pass
class EmailDomainSuspendedError(AccountRegisterError):
def __init__(self, description: str = "This email domain has been suspended."):
super().__init__(description)
class AccountLoginError(BaseServiceError):
pass
@@ -9,13 +9,22 @@ from flask import Flask
from controllers.console.auth.activate import ActivateApi, ActivateCheckApi
from controllers.console.auth.error import InvitationAccountMismatchError as InvitationAccountMismatchHTTPError
from controllers.console.error import AccountInFreezeError, AlreadyActivateError
from controllers.console.error import (
AccountInFreezeError,
AlreadyActivateError,
)
from controllers.console.error import (
EmailDomainSuspendedError as EmailDomainSuspendedHTTPError,
)
from services.account_activation_service import (
AccountActivationService,
FrozenAccountError,
InvalidInvitationError,
InvitationAccountMismatchError,
)
from services.account_activation_service import (
EmailDomainSuspendedError as EmailDomainSuspendedRegistrationError,
)
from services.entities.account_activation_entities import (
ActivationCheckData,
ActivationCheckResult,
@@ -172,6 +181,7 @@ class TestActivateApi:
(InvalidInvitationError(), AlreadyActivateError),
(InvitationAccountMismatchError(), InvitationAccountMismatchHTTPError),
(FrozenAccountError(), AccountInFreezeError),
(EmailDomainSuspendedRegistrationError(), EmailDomainSuspendedHTTPError),
],
)
def test_translates_application_errors(
@@ -4,6 +4,7 @@ from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from controllers.console.auth.email_register import (
@@ -11,14 +12,21 @@ from controllers.console.auth.email_register import (
EmailRegisterResetApi,
EmailRegisterSendEmailApi,
)
from controllers.console.error import AccountInFreezeError, EmailDomainSuspendedError
from enums import DeploymentEdition
from services.entities.feature_entities import SystemFeatureModel
from services.errors.account import (
AccountRegisterError,
)
from services.errors.account import (
EmailDomainSuspendedError as EmailDomainSuspendedRegistrationError,
)
class TestEmailRegisterSendEmailApi:
@patch("controllers.console.auth.email_register.AccountService.get_account_by_email_with_case_fallback")
@patch("controllers.console.auth.email_register.AccountService.send_email_register_email")
@patch("controllers.console.auth.email_register.BillingService.is_email_in_freeze")
@patch("controllers.console.auth.email_register.BillingService.get_email_freeze_type")
@patch("controllers.console.auth.email_register.AccountService.is_email_send_ip_limit", return_value=False)
@patch("controllers.console.auth.email_register.extract_remote_ip", return_value="127.0.0.1")
def test_send_email_normalizes_and_falls_back(
@@ -31,7 +39,7 @@ class TestEmailRegisterSendEmailApi:
app: Flask,
):
mock_send_mail.return_value = "token-123"
mock_is_freeze.return_value = False
mock_is_freeze.return_value = None
mock_account = MagicMock()
mock_get_account.return_value = mock_account
@@ -58,6 +66,49 @@ class TestEmailRegisterSendEmailApi:
mock_extract_ip.assert_called_once()
mock_is_email_send_ip_limit.assert_called_once_with("127.0.0.1")
@pytest.mark.parametrize(
("freeze_type", "expected_error"),
[
("freeze", AccountInFreezeError),
("email_domain_suspended", EmailDomainSuspendedError),
],
)
@patch("controllers.console.auth.email_register.BillingService.get_email_freeze_type")
@patch("controllers.console.auth.email_register.AccountService.is_email_send_ip_limit", return_value=False)
@patch("controllers.console.auth.email_register.extract_remote_ip", return_value="127.0.0.1")
def test_send_email_rejects_frozen_email(
self,
mock_extract_ip,
mock_is_email_send_ip_limit,
mock_get_freeze_type,
app: Flask,
freeze_type,
expected_error,
):
mock_get_freeze_type.return_value = freeze_type
feature_flags = SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
enable_email_password_login=True,
is_allow_register=True,
)
with (
patch("controllers.console.auth.email_register.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD),
patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD),
patch("controllers.console.wraps.FeatureService.get_system_features", return_value=feature_flags),
):
with app.test_request_context(
"/email-register/send-email",
method="POST",
json={"email": "Invitee@Example.com"},
):
with pytest.raises(expected_error):
EmailRegisterSendEmailApi().post()
mock_get_freeze_type.assert_called_once_with("invitee@example.com")
mock_is_email_send_ip_limit.assert_called_once_with("127.0.0.1")
mock_extract_ip.assert_called_once()
class TestEmailRegisterCheckApi:
@patch("controllers.console.auth.email_register.AccountService.reset_email_register_error_rate_limit")
@@ -107,6 +158,28 @@ class TestEmailRegisterCheckApi:
class TestEmailRegisterResetApi:
@pytest.mark.parametrize(
("service_error", "expected_error"),
[
(EmailDomainSuspendedRegistrationError(), EmailDomainSuspendedError),
(AccountRegisterError("frozen"), AccountInFreezeError),
],
)
@patch("controllers.console.auth.email_register.AccountService.create_account_and_tenant")
def test_create_new_account_translates_freeze_errors(
self,
mock_create_account,
service_error,
expected_error,
):
mock_create_account.side_effect = service_error
with pytest.raises(expected_error):
EmailRegisterResetApi()._create_new_account(
email="user@example.com",
password="ValidPass123!",
)
@patch("controllers.console.auth.email_register.AccountService.reset_login_error_rate_limit")
@patch("controllers.console.auth.email_register.AccountService.login")
@patch("controllers.console.auth.email_register.EmailRegisterResetApi._create_new_account")
@@ -33,6 +33,7 @@ from controllers.console.auth.login import (
from controllers.console.error import (
AccountInFreezeError,
AccountNotFound,
EmailDomainSuspendedError,
EmailSendIpLimitError,
NotAllowedCreateWorkspace,
WorkspacesLimitExceeded,
@@ -43,7 +44,12 @@ from services.email_code_login_challenge import (
EmailCodeLoginChallengeStatus,
EmailCodeLoginChallengeUnavailableError,
)
from services.errors.account import AccountRegisterError
from services.errors.account import (
AccountRegisterError,
)
from services.errors.account import (
EmailDomainSuspendedError as EmailDomainSuspendedRegistrationError,
)
from services.turnstile_service import TurnstileChallengeRejectedError, TurnstileUpstreamError
TEST_TOKEN = "00000000-0000-4000-8000-000000000001"
@@ -308,7 +314,22 @@ class TestEmailCodeLoginSendEmailApi:
@patch("controllers.console.wraps.db")
@patch("controllers.console.auth.login.AccountService.is_email_send_ip_limit")
@patch("controllers.console.auth.login.AccountService.get_user_through_email")
def test_send_email_code_frozen_account(self, mock_get_user, mock_is_ip_limit, mock_db, app: Flask):
@pytest.mark.parametrize(
("service_error", "expected_error"),
[
(AccountRegisterError("Account frozen"), AccountInFreezeError),
(EmailDomainSuspendedRegistrationError(), EmailDomainSuspendedError),
],
)
def test_send_email_code_frozen_account(
self,
mock_get_user,
mock_is_ip_limit,
mock_db,
app: Flask,
service_error,
expected_error,
):
"""
Test email code sending to frozen account.
@@ -317,12 +338,12 @@ class TestEmailCodeLoginSendEmailApi:
"""
# Arrange
mock_is_ip_limit.return_value = False
mock_get_user.side_effect = AccountRegisterError("Account frozen")
mock_get_user.side_effect = service_error
# Act & Assert
with app.test_request_context("/email-code-login", method="POST", json={"email": "frozen@example.com"}):
api = EmailCodeLoginSendEmailApi()
with pytest.raises(AccountInFreezeError):
with pytest.raises(expected_error):
api.post()
@pytest.mark.parametrize(
@@ -22,17 +22,26 @@ from controllers.console.auth.error import (
EmailPasswordLoginLimitError,
InvalidEmailError,
)
from controllers.console.auth.login import EmailCodeLoginApi, LoginApi, LogoutApi
from controllers.console.auth.login import EmailCodeLoginApi, LoginApi, LogoutApi, ResetPasswordSendEmailApi
from controllers.console.error import (
AccountBannedError,
AccountInFreezeError,
EmailDomainSuspendedError,
SeatsLimitExceeded,
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
from services.errors.account import (
AccountLoginError,
AccountPasswordError,
AccountRegisterError,
SeatsLimitExceededError,
)
from services.errors.account import (
EmailDomainSuspendedError as EmailDomainSuspendedRegistrationError,
)
TEST_TOKEN = "00000000-0000-4000-8000-000000000001"
@@ -228,7 +237,7 @@ class TestLoginApi:
@patch("controllers.console.wraps.db")
@patch("controllers.console.auth.login.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD)
@patch("controllers.console.auth.login.BillingService.is_email_in_freeze")
@patch("controllers.console.auth.login.BillingService.get_email_freeze_type")
def test_login_fails_when_account_frozen(
self, mock_is_frozen, mock_db, app: Flask, caplog: pytest.LogCaptureFixture
):
@@ -240,7 +249,7 @@ class TestLoginApi:
- AccountInFreezeError is raised for frozen accounts
"""
# Arrange
mock_is_frozen.return_value = True
mock_is_frozen.return_value = "freeze"
# Act & Assert
with app.test_request_context(
@@ -257,6 +266,116 @@ class TestLoginApi:
assert warn_records[0].args[0] == "frozen@example.com"
assert warn_records[0].args[1] == LoginFailureReason.ACCOUNT_IN_FREEZE
@patch("controllers.console.wraps.db")
@patch("controllers.console.auth.login.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD)
@patch("controllers.console.auth.login.BillingService.get_email_freeze_type")
def test_login_fails_when_email_domain_is_suspended(self, mock_get_freeze_type, mock_db, app: Flask):
mock_get_freeze_type.return_value = "email_domain_suspended"
with app.test_request_context(
"/login",
method="POST",
json={"email": "user@suspended.example", "password": encode_password("password")},
):
with pytest.raises(EmailDomainSuspendedError):
LoginApi().post()
@pytest.mark.parametrize(
("service_error", "expected_error"),
[
(EmailDomainSuspendedRegistrationError(), EmailDomainSuspendedError),
(AccountRegisterError("frozen"), AccountInFreezeError),
],
)
@patch("controllers.console.wraps.db")
@patch("controllers.console.auth.login._get_account_with_case_fallback")
@patch("controllers.console.auth.login.AccountService.verify_email_code_login_challenge")
def test_email_code_login_translates_freeze_errors(
self,
mock_verify_challenge,
mock_get_account,
mock_db,
app: Flask,
service_error,
expected_error,
):
mock_verify_challenge.return_value = EmailCodeLoginChallengeResult(
status=EmailCodeLoginChallengeStatus.VERIFIED
)
mock_get_account.side_effect = service_error
with app.test_request_context(
"/email-code-login/validity",
method="POST",
json={"email": "User@Example.com", "code": encode_code("123456"), "token": TEST_TOKEN},
):
with pytest.raises(expected_error):
EmailCodeLoginApi().post()
@pytest.mark.parametrize(
("service_error", "expected_error"),
[
(EmailDomainSuspendedRegistrationError(), EmailDomainSuspendedError),
(AccountRegisterError("frozen"), AccountInFreezeError),
],
)
@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.verify_email_code_login_challenge")
@patch("controllers.console.auth.login._get_account_with_case_fallback")
def test_email_code_login_translates_account_creation_freeze_errors(
self,
mock_get_account,
mock_verify_challenge,
mock_create_account,
mock_login_db,
mock_db,
app: Flask,
service_error,
expected_error,
):
mock_verify_challenge.return_value = EmailCodeLoginChallengeResult(
status=EmailCodeLoginChallengeStatus.VERIFIED
)
mock_get_account.return_value = None
mock_create_account.side_effect = service_error
with app.test_request_context(
"/email-code-login/validity",
method="POST",
json={"email": "User@Example.com", "code": encode_code("123456"), "token": TEST_TOKEN},
):
with pytest.raises(expected_error):
EmailCodeLoginApi().post()
@pytest.mark.parametrize(
("service_error", "expected_error"),
[
(EmailDomainSuspendedRegistrationError(), EmailDomainSuspendedError),
(AccountRegisterError("frozen"), AccountInFreezeError),
],
)
@patch("controllers.console.wraps.db")
@patch("controllers.console.auth.login._get_account_with_case_fallback")
def test_reset_password_translates_freeze_errors(
self,
mock_get_account,
mock_db,
app: Flask,
service_error,
expected_error,
):
mock_get_account.side_effect = service_error
with app.test_request_context(
"/reset-password",
method="POST",
json={"email": "User@Example.com"},
):
with pytest.raises(expected_error):
ResetPasswordSendEmailApi().post()
@patch("controllers.console.wraps.db")
@patch("controllers.console.auth.login.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY)
@patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit")
@@ -14,9 +14,15 @@ from controllers.console.auth.oauth import (
_get_account_by_openid_or_email,
get_oauth_providers,
)
from enums import DeploymentEdition
from libs.oauth import OAuthUserInfo, encode_oauth_state
from models.account import AccountStatus
from services.errors.account import AccountRegisterError
from services.errors.account import (
AccountRegisterError,
)
from services.errors.account import (
EmailDomainSuspendedError as EmailDomainSuspendedRegistrationError,
)
@pytest.fixture(autouse=True)
@@ -231,6 +237,38 @@ class TestOAuthCallback:
)
mock_redirect.assert_called_once_with("http://localhost:3000?oauth_new_user=true")
@pytest.mark.parametrize(
("service_error", "expected_message"),
[
(
EmailDomainSuspendedRegistrationError(),
"This email domain has been suspended.",
),
(AccountRegisterError("This email account is frozen."), "This email account is frozen."),
],
)
@patch("controllers.console.auth.oauth.get_oauth_providers")
@patch("controllers.console.auth.oauth._generate_account")
@patch("controllers.console.auth.oauth.redirect")
def test_should_translate_registration_freeze_errors(
self,
mock_redirect,
mock_generate_account,
mock_get_providers,
resource: OAuthCallback,
app: Flask,
oauth_setup,
service_error,
expected_message,
):
mock_get_providers.return_value = {"github": oauth_setup["provider"]}
mock_generate_account.side_effect = service_error
with app.test_request_context("/auth/oauth/github/callback?code=test_code"):
resource.get("github")
mock_redirect.assert_called_once_with(f"http://localhost:3000/signin?message={expected_message}")
@pytest.mark.parametrize(
("exception", "expected_error"),
[
@@ -537,6 +575,36 @@ class TestAccountGeneration:
else:
mock_register_service.register.assert_not_called()
@pytest.mark.parametrize(
("freeze_type", "expected_error"),
[
("email_domain_suspended", EmailDomainSuspendedRegistrationError),
("freeze", AccountRegisterError),
],
)
@patch("controllers.console.auth.oauth.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD)
@patch("controllers.console.auth.oauth.BillingService.get_email_freeze_type")
@patch("controllers.console.auth.oauth._get_account_by_openid_or_email", return_value=None)
@patch("controllers.console.auth.oauth.FeatureService")
def test_should_reject_registration_for_frozen_email(
self,
mock_feature_service,
mock_get_account,
mock_get_freeze_type,
freeze_type,
expected_error,
app: Flask,
user_info: OAuthUserInfo,
):
mock_feature_service.get_system_features.return_value.is_allow_register = False
mock_get_freeze_type.return_value = freeze_type
with app.test_request_context("/"):
with pytest.raises(expected_error):
_generate_account("github", user_info)
mock_get_freeze_type.assert_called_once_with("test@example.com")
@patch("controllers.console.auth.oauth._get_account_by_openid_or_email", return_value=None)
@patch("controllers.console.auth.oauth.FeatureService")
@patch("controllers.console.auth.oauth.RegisterService")
@@ -8,6 +8,7 @@ import pytest
from flask import Flask
from sqlalchemy.orm import Session, scoped_session, sessionmaker
from controllers.console.error import EmailDomainSuspendedError
from controllers.console.workspace.account import (
AccountDeleteUpdateFeedbackApi,
ChangeEmailCheckApi,
@@ -442,10 +443,29 @@ class TestChangeEmailValidity:
class TestChangeEmailReset:
@patch(
"controllers.console.workspace.account.AccountService.get_account_freeze_type",
return_value="email_domain_suspended",
)
def test_should_reject_suspended_email_domain(self, mock_get_freeze_type, app: Flask):
current_user = _build_account("old@example.com", "email-reset-account")
with app.test_request_context(
"/account/change-email/reset",
method="POST",
json={"new_email": "new@suspended.example", "token": "token-123"},
):
api = ChangeEmailResetApi()
method = inspect.unwrap(api.post)
with pytest.raises(EmailDomainSuspendedError):
method(api, current_user)
mock_get_freeze_type.assert_called_once_with("new@suspended.example")
@patch("controllers.console.workspace.account.AccountService.send_change_email_completed_notify_email")
@patch("controllers.console.workspace.account.AccountService.revoke_change_email_token")
@patch("controllers.console.workspace.account.AccountService.get_change_email_data")
@patch("controllers.console.workspace.account.AccountService.is_account_in_freeze")
@patch("controllers.console.workspace.account.AccountService.get_account_freeze_type")
@pytest.mark.parametrize(
"sqlite_session",
[(Account, Tenant, TenantAccountJoin, AccountIntegrate)],
@@ -507,7 +527,7 @@ class TestChangeEmailReset:
@patch("controllers.console.workspace.account.AccountService.revoke_change_email_token")
@patch("controllers.console.workspace.account.AccountService.get_change_email_data")
@patch("controllers.console.workspace.account.AccountService.check_email_unique")
@patch("controllers.console.workspace.account.AccountService.is_account_in_freeze")
@patch("controllers.console.workspace.account.AccountService.get_account_freeze_type")
def test_should_reject_reset_when_token_phase_is_not_new_verified(
self,
mock_is_freeze: MagicMock,
@@ -550,7 +570,7 @@ class TestChangeEmailReset:
@patch("controllers.console.workspace.account.AccountService.revoke_change_email_token")
@patch("controllers.console.workspace.account.AccountService.get_change_email_data")
@patch("controllers.console.workspace.account.AccountService.check_email_unique")
@patch("controllers.console.workspace.account.AccountService.is_account_in_freeze")
@patch("controllers.console.workspace.account.AccountService.get_account_freeze_type")
def test_should_reject_reset_when_token_email_differs_from_payload_new_email(
self,
mock_is_freeze: MagicMock,
@@ -593,7 +613,7 @@ class TestChangeEmailReset:
@patch("controllers.console.workspace.account.AccountService.revoke_change_email_token")
@patch("controllers.console.workspace.account.AccountService.get_change_email_data")
@patch("controllers.console.workspace.account.AccountService.check_email_unique")
@patch("controllers.console.workspace.account.AccountService.is_account_in_freeze")
@patch("controllers.console.workspace.account.AccountService.get_account_freeze_type")
def test_should_reject_reset_when_token_account_id_does_not_match_current_user(
self,
mock_is_freeze: MagicMock,
@@ -736,7 +756,7 @@ class TestAccountDeletionFeedback:
class TestCheckEmailUnique:
@patch("controllers.console.workspace.account.AccountService.is_account_in_freeze")
@patch("controllers.console.workspace.account.AccountService.get_account_freeze_type")
@pytest.mark.parametrize(
"sqlite_session",
[(Account, Tenant, TenantAccountJoin)],
@@ -15,7 +15,7 @@ from controllers.console.auth.error import (
EmailAlreadyInUseError,
EmailCodeError,
)
from controllers.console.error import AccountInFreezeError
from controllers.console.error import AccountInFreezeError, EmailDomainSuspendedError
from controllers.console.workspace.account import (
AccountAvatarApi,
AccountAvatarQuery,
@@ -603,7 +603,7 @@ class TestChangeEmailApis:
new_callable=PropertyMock,
return_value=payload,
),
patch("controllers.console.workspace.account.AccountService.is_account_in_freeze", return_value=False),
patch("controllers.console.workspace.account.AccountService.get_account_freeze_type", return_value=None),
patch("controllers.console.workspace.account.AccountService.check_email_unique", return_value=False),
):
with pytest.raises(EmailAlreadyInUseError):
@@ -625,7 +625,7 @@ class TestCheckEmailUniqueApi:
new_callable=PropertyMock,
return_value=payload,
),
patch("controllers.console.workspace.account.AccountService.is_account_in_freeze", return_value=False),
patch("controllers.console.workspace.account.AccountService.get_account_freeze_type", return_value=None),
patch("controllers.console.workspace.account.AccountService.check_email_unique", return_value=True),
):
result = method(api)
@@ -646,7 +646,32 @@ class TestCheckEmailUniqueApi:
new_callable=PropertyMock,
return_value=payload,
),
patch("controllers.console.workspace.account.AccountService.is_account_in_freeze", return_value=True),
patch(
"controllers.console.workspace.account.AccountService.get_account_freeze_type",
return_value="freeze",
),
):
with pytest.raises(AccountInFreezeError):
method(api)
def test_email_domain_is_suspended(self, app: Flask):
api = CheckEmailUnique()
method = inspect.unwrap(api.post)
payload = {"email": "user@suspended.example"}
with (
app.test_request_context("/", json=payload),
patch.object(
type(console_ns),
"payload",
new_callable=PropertyMock,
return_value=payload,
),
patch(
"controllers.console.workspace.account.AccountService.get_account_freeze_type",
return_value="email_domain_suspended",
),
):
with pytest.raises(EmailDomainSuspendedError):
method(api)
@@ -42,11 +42,22 @@ def test_invitation_token_store_revokes_with_legacy_key_inputs() -> None:
def test_billing_eligibility_skips_gateway_when_disabled() -> None:
with patch("services.account_activation_adapters.BillingService.is_email_in_freeze") as is_frozen:
result = BillingAccountActivationEligibility(enabled=False).is_frozen("invitee@example.com")
with patch("services.account_activation_adapters.BillingService.get_email_freeze_type") as get_freeze_type:
result = BillingAccountActivationEligibility(enabled=False).get_freeze_type("invitee@example.com")
assert result is False
is_frozen.assert_not_called()
assert result is None
get_freeze_type.assert_not_called()
def test_billing_eligibility_returns_freeze_type_when_enabled() -> None:
with patch(
"services.account_activation_adapters.BillingService.get_email_freeze_type",
return_value="email_domain_suspended",
) as get_freeze_type:
result = BillingAccountActivationEligibility(enabled=True).get_freeze_type("invitee@example.com")
assert result == "email_domain_suspended"
get_freeze_type.assert_called_once_with("invitee@example.com")
def test_membership_cache_skips_gateway_when_disabled() -> None:
@@ -6,6 +6,7 @@ from services.account_activation_service import (
AccountActivationEligibility,
AccountActivationRepository,
AccountActivationService,
EmailDomainSuspendedError,
FrozenAccountError,
InvalidInvitationError,
InvitationAccountMismatchError,
@@ -60,7 +61,7 @@ def _service() -> tuple[AccountActivationService, Mock, Mock, Mock, Mock, Mock]:
policy = Mock(spec=WorkspaceInvitePolicy)
eligibility = Mock(spec=AccountActivationEligibility)
membership_cache = Mock(spec=WorkspaceMembershipCache)
eligibility.is_frozen.return_value = False
eligibility.get_freeze_type.return_value = None
service = AccountActivationService(
tokens=tokens,
accounts=accounts,
@@ -133,7 +134,7 @@ class TestActivateInvitation:
authenticated_account_id="different-account",
)
eligibility.is_frozen.assert_not_called()
eligibility.get_freeze_type.assert_not_called()
tokens.revoke.assert_not_called()
accounts.activate.assert_not_called()
@@ -141,12 +142,12 @@ class TestActivateInvitation:
service, tokens, accounts, _, eligibility, _ = _service()
tokens.find.return_value = _token()
accounts.resolve.return_value = _invitation()
eligibility.is_frozen.return_value = True
eligibility.get_freeze_type.return_value = "freeze"
with pytest.raises(FrozenAccountError):
service.activate(ActivationCommand(invitation=_lookup()), authenticated_account_id=None)
eligibility.is_frozen.assert_called_once_with("invitee@example.com")
eligibility.get_freeze_type.assert_called_once_with("invitee@example.com")
tokens.revoke.assert_not_called()
accounts.activate.assert_not_called()
@@ -164,6 +165,19 @@ class TestActivateInvitation:
tokens.revoke.assert_not_called()
accounts.activate.assert_not_called()
def test_rejects_suspended_email_domain_without_consuming_token(self) -> None:
service, tokens, accounts, _, eligibility, _ = _service()
tokens.find.return_value = _token()
accounts.resolve.return_value = _invitation()
eligibility.get_freeze_type.return_value = "email_domain_suspended"
with pytest.raises(EmailDomainSuspendedError):
service.activate(ActivationCommand(invitation=_lookup()), authenticated_account_id=None)
eligibility.get_freeze_type.assert_called_once_with("invitee@example.com")
tokens.revoke.assert_not_called()
accounts.activate.assert_not_called()
def test_activates_anonymous_invitation_and_invalidates_new_membership_cache(self) -> None:
service, tokens, accounts, _, eligibility, membership_cache = _service()
tokens.find.return_value = _token()
@@ -179,7 +193,7 @@ class TestActivateInvitation:
service.activate(command, authenticated_account_id=None)
eligibility.is_frozen.assert_called_once_with("invitee@example.com")
eligibility.get_freeze_type.assert_called_once_with("invitee@example.com")
tokens.revoke.assert_called_once_with(_lookup("invitee@example.com"))
accounts.activate.assert_called_once_with(
invitation,
@@ -29,6 +29,7 @@ from services.errors.account import (
AccountPasswordError,
AccountRegisterError,
CurrentPasswordIncorrectError,
EmailDomainSuspendedError,
NoPermissionError,
)
@@ -312,6 +313,50 @@ class TestAccountService:
session=unbound_session,
)
def test_create_account_suspended_email_domain(
self, unbound_session: Session, mock_external_service_dependencies: _MockDependencies
) -> None:
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = True
mock_external_service_dependencies[
"billing_service"
].get_email_freeze_type.return_value = "email_domain_suspended"
with patch("services.account_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD):
with pytest.raises(EmailDomainSuspendedError):
AccountService.create_account(
email="user@suspended.example",
name="Test User",
interface_language="en-US",
session=unbound_session,
)
def test_get_user_through_email_rejects_suspended_email_domain(
self, unbound_session: Session, mock_external_service_dependencies: _MockDependencies
) -> None:
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = True
mock_external_service_dependencies[
"billing_service"
].get_email_freeze_type.return_value = "email_domain_suspended"
with patch("services.account_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD):
with pytest.raises(EmailDomainSuspendedError):
AccountService.get_user_through_email("user@suspended.example", session=unbound_session)
def test_get_account_freeze_type_is_enabled_only_for_cloud(
self, mock_external_service_dependencies: _MockDependencies
) -> None:
mock_external_service_dependencies["billing_service"].get_email_freeze_type.return_value = "freeze"
with patch("services.account_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD):
assert AccountService.get_account_freeze_type("frozen@example.com") == "freeze"
with patch("services.account_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY):
assert AccountService.get_account_freeze_type("frozen@example.com") is None
mock_external_service_dependencies["billing_service"].get_email_freeze_type.assert_called_once_with(
"frozen@example.com"
)
def test_create_account_without_password(
self,
sqlite_session_factory: sessionmaker[Session],
@@ -1297,6 +1297,15 @@ class TestBillingServiceAccountManagement:
assert result is True
mock_send_request.assert_called_once_with("GET", "/account/in-freeze", params={"email": email})
def test_get_email_freeze_type_for_suspended_domain(self, mock_send_request):
email = "user@suspended.example"
mock_send_request.return_value = {"data": True, "freezeType": "email_domain_suspended"}
result = BillingService.get_email_freeze_type(email)
assert result == "email_domain_suspended"
mock_send_request.assert_called_once_with("GET", "/account/in-freeze", params={"email": email})
def test_is_email_in_freeze_false(self, mock_send_request):
"""Test checking if email is frozen (returns False)."""
# Arrange