mirror of
https://github.com/langgenius/dify.git
synced 2026-08-30 20:43:55 +08:00
202 lines
7.4 KiB
Python
202 lines
7.4 KiB
Python
"""Application service implementing the account change-email state machine."""
|
|
|
|
from machinery.context import RequestContext
|
|
from services.account_change_email_ports import (
|
|
AccountEmailPolicyGateway,
|
|
ChangeEmailCodeGenerator,
|
|
ChangeEmailNotificationGateway,
|
|
ChangeEmailSecurityGateway,
|
|
ChangeEmailSendLimiter,
|
|
ChangeEmailTokenGateway,
|
|
)
|
|
from services.account_errors import (
|
|
AccountEmailAlreadyInUseError,
|
|
AccountEmailDomainSuspendedError,
|
|
AccountEmailFrozenError,
|
|
AccountNotFoundError,
|
|
ChangeEmailSendIPLimitedError,
|
|
ChangeEmailSendRateLimitError,
|
|
ChangeEmailVerificationLimitError,
|
|
InvalidChangeEmailAddressError,
|
|
InvalidChangeEmailCodeError,
|
|
InvalidChangeEmailTokenError,
|
|
)
|
|
from services.account_ports import AccountRepository
|
|
from services.entities.account_entities import (
|
|
AccountChangeEmailNewEmailToken,
|
|
AccountChangeEmailNewEmailVerifiedToken,
|
|
AccountChangeEmailOldEmailToken,
|
|
AccountChangeEmailOldEmailVerifiedToken,
|
|
AccountChangeEmailPhase,
|
|
AccountEmailResetStatus,
|
|
AccountSnapshot,
|
|
ChangeEmailVerification,
|
|
)
|
|
|
|
|
|
class AccountChangeEmailService:
|
|
def __init__(
|
|
self,
|
|
*,
|
|
accounts: AccountRepository,
|
|
tokens: ChangeEmailTokenGateway,
|
|
codes: ChangeEmailCodeGenerator,
|
|
notifications: ChangeEmailNotificationGateway,
|
|
send_limits: ChangeEmailSendLimiter,
|
|
security: ChangeEmailSecurityGateway,
|
|
email_policy: AccountEmailPolicyGateway,
|
|
) -> None:
|
|
self._accounts = accounts
|
|
self._tokens = tokens
|
|
self._codes = codes
|
|
self._notifications = notifications
|
|
self._send_limits = send_limits
|
|
self._security = security
|
|
self._email_policy = email_policy
|
|
|
|
def send_code(
|
|
self,
|
|
context: RequestContext,
|
|
*,
|
|
requested_email: str,
|
|
language: str,
|
|
phase: str | None,
|
|
predecessor_token: str | None,
|
|
ip_address: str,
|
|
) -> str:
|
|
if self._security.is_ip_limited(ip_address):
|
|
raise ChangeEmailSendIPLimitedError
|
|
|
|
account = self._accounts.get(context.account_id)
|
|
if account is None:
|
|
raise AccountNotFoundError
|
|
|
|
email_for_sending = requested_email.lower()
|
|
old_email = account.email
|
|
send_phase = AccountChangeEmailPhase.OLD_EMAIL
|
|
if phase == AccountChangeEmailPhase.NEW_EMAIL:
|
|
send_phase = AccountChangeEmailPhase.NEW_EMAIL
|
|
if predecessor_token is None:
|
|
raise InvalidChangeEmailTokenError
|
|
predecessor = self._tokens.get(predecessor_token)
|
|
if not isinstance(predecessor, AccountChangeEmailOldEmailVerifiedToken):
|
|
raise InvalidChangeEmailTokenError
|
|
if not predecessor.is_bound_to_account(context.account_id):
|
|
raise InvalidChangeEmailTokenError
|
|
old_email = predecessor.email
|
|
if old_email.lower() != account.email.lower():
|
|
raise InvalidChangeEmailAddressError
|
|
else:
|
|
if email_for_sending != account.email.lower():
|
|
raise InvalidChangeEmailAddressError
|
|
email_for_sending = account.email
|
|
|
|
if self._send_limits.is_limited(email_for_sending):
|
|
raise ChangeEmailSendRateLimitError(self._send_limits.retry_after_minutes)
|
|
|
|
code = self._codes.generate()
|
|
token_data: AccountChangeEmailOldEmailToken | AccountChangeEmailNewEmailToken
|
|
if send_phase == AccountChangeEmailPhase.OLD_EMAIL:
|
|
token_data = AccountChangeEmailOldEmailToken(
|
|
account_id=context.account_id,
|
|
email=email_for_sending,
|
|
old_email=old_email,
|
|
code=code,
|
|
)
|
|
else:
|
|
token_data = AccountChangeEmailNewEmailToken(
|
|
account_id=context.account_id,
|
|
email=email_for_sending,
|
|
old_email=old_email,
|
|
code=code,
|
|
)
|
|
token = self._tokens.issue(token_data)
|
|
self._notifications.send_code(
|
|
email=email_for_sending,
|
|
code=code,
|
|
language=language,
|
|
phase=send_phase,
|
|
)
|
|
self._send_limits.record(email_for_sending)
|
|
return token
|
|
|
|
def verify_code(
|
|
self,
|
|
context: RequestContext,
|
|
*,
|
|
email: str,
|
|
code: str,
|
|
token: str,
|
|
) -> ChangeEmailVerification:
|
|
normalized_email = email.lower()
|
|
if self._security.is_verification_limited(normalized_email):
|
|
raise ChangeEmailVerificationLimitError
|
|
|
|
token_data = self._tokens.get(token)
|
|
if token_data is None or not token_data.is_bound_to_account(context.account_id):
|
|
raise InvalidChangeEmailTokenError
|
|
normalized_token_email = token_data.email.lower()
|
|
if normalized_email != normalized_token_email:
|
|
raise InvalidChangeEmailAddressError
|
|
if code != token_data.code:
|
|
self._security.record_verification_failure(normalized_email)
|
|
raise InvalidChangeEmailCodeError
|
|
|
|
if isinstance(token_data, AccountChangeEmailOldEmailToken | AccountChangeEmailNewEmailToken):
|
|
promoted = token_data.promote()
|
|
else:
|
|
raise InvalidChangeEmailTokenError
|
|
|
|
self._tokens.revoke(token)
|
|
promoted_token = self._tokens.issue(promoted)
|
|
self._security.reset_verification_failures(normalized_email)
|
|
return ChangeEmailVerification(email=normalized_token_email, token=promoted_token)
|
|
|
|
def reset(
|
|
self,
|
|
context: RequestContext,
|
|
*,
|
|
new_email: str,
|
|
token: str,
|
|
) -> AccountSnapshot:
|
|
normalized_new_email = new_email.lower()
|
|
freeze_type = self._email_policy.is_frozen(normalized_new_email)
|
|
if freeze_type == "email_domain_suspended":
|
|
raise AccountEmailDomainSuspendedError
|
|
if freeze_type:
|
|
raise AccountEmailFrozenError
|
|
|
|
token_data = self._tokens.get(token)
|
|
if not isinstance(token_data, AccountChangeEmailNewEmailVerifiedToken):
|
|
raise InvalidChangeEmailTokenError
|
|
if not token_data.is_bound_to_account(context.account_id):
|
|
raise InvalidChangeEmailTokenError
|
|
if token_data.email.lower() != normalized_new_email:
|
|
raise InvalidChangeEmailTokenError
|
|
|
|
result = self._accounts.reset_email(
|
|
context.account_id,
|
|
expected_old_email=token_data.old_email,
|
|
new_email=normalized_new_email,
|
|
)
|
|
if result.status == AccountEmailResetStatus.EMAIL_IN_USE:
|
|
raise AccountEmailAlreadyInUseError
|
|
if result.status in {AccountEmailResetStatus.ACCOUNT_NOT_FOUND, AccountEmailResetStatus.EMAIL_CHANGED}:
|
|
raise AccountNotFoundError
|
|
if result.account is None:
|
|
raise RuntimeError("Account repository returned an updated result without an account")
|
|
|
|
self._tokens.revoke(token)
|
|
self._notifications.send_completed(email=normalized_new_email, language="en-US")
|
|
return result.account
|
|
|
|
def ensure_available(self, email: str) -> None:
|
|
normalized_email = email.lower()
|
|
freeze_type = self._email_policy.is_frozen(normalized_email)
|
|
if freeze_type == "email_domain_suspended":
|
|
raise AccountEmailDomainSuspendedError
|
|
if freeze_type:
|
|
raise AccountEmailFrozenError
|
|
if self._accounts.email_exists(normalized_email):
|
|
raise AccountEmailAlreadyInUseError
|