dify/api/services/account_forgot_password_service.py
Byron.wang be7d11ca1d
refactor(api): extract forgot password application service (#41111)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-09-03 01:38:59 +00:00

187 lines
6.0 KiB
Python

"""Application service implementing the account forgot-password use case."""
from typing import Protocol
from services.account_errors import (
AccountNotFoundError,
ForgotPasswordMismatchError,
ForgotPasswordSendIPLimitedError,
ForgotPasswordSendRateLimitError,
ForgotPasswordVerificationLimitError,
InvalidForgotPasswordCodeError,
InvalidForgotPasswordEmailError,
InvalidForgotPasswordTokenError,
)
from services.account_ports import AccountPasswordHasher, AccountRepository
from services.entities.account_entities import (
ForgotPasswordResetToken,
ForgotPasswordToken,
ForgotPasswordVerification,
ForgotPasswordVerificationToken,
)
FORGOT_PASSWORD_SEND_RATE_LIMIT_PREFIX = "reset_password_rate_limit"
FORGOT_PASSWORD_SEND_RATE_LIMIT_MAX_ATTEMPTS = 1
FORGOT_PASSWORD_SEND_RATE_LIMIT_WINDOW_SECONDS = 60
FORGOT_PASSWORD_VERIFICATION_FAILURE_LIMIT = 5
FORGOT_PASSWORD_VERIFICATION_KEY_PREFIX = "forgot_password_error_rate_limit"
class ForgotPasswordTokenGateway(Protocol):
def read_verification(self, token: str) -> ForgotPasswordVerificationToken | None: ...
def claim_reset(self, token: str) -> ForgotPasswordResetToken | None: ...
def issue(self, token_data: ForgotPasswordToken) -> str: ...
def promote(self, claimed_token: str, token_data: ForgotPasswordResetToken) -> str | None: ...
class ForgotPasswordCodeGenerator(Protocol):
def generate(self) -> str: ...
class ForgotPasswordNotificationGateway(Protocol):
def send(
self,
*,
email: str,
code: str,
language: str,
account_exists: bool,
registration_allowed: bool,
) -> None: ...
class ForgotPasswordSendLimiter(Protocol):
def is_limited(self, email: str) -> bool: ...
def record(self, email: str) -> None: ...
@property
def retry_after_minutes(self) -> int: ...
class ForgotPasswordSecurityGateway(Protocol):
def is_ip_limited(self, ip_address: str) -> bool: ...
def is_verification_limited(self, email: str) -> bool: ...
def record_verification_failure(self, email: str) -> None: ...
def reset_verification_failures(self, email: str) -> None: ...
class ForgotPasswordRegistrationPolicy(Protocol):
def is_registration_allowed(self) -> bool: ...
class AccountForgotPasswordService:
def __init__(
self,
*,
accounts: AccountRepository,
passwords: AccountPasswordHasher,
tokens: ForgotPasswordTokenGateway,
codes: ForgotPasswordCodeGenerator,
notifications: ForgotPasswordNotificationGateway,
send_limits: ForgotPasswordSendLimiter,
security: ForgotPasswordSecurityGateway,
registration: ForgotPasswordRegistrationPolicy,
) -> None:
self._accounts = accounts
self._passwords = passwords
self._tokens = tokens
self._codes = codes
self._notifications = notifications
self._send_limits = send_limits
self._security = security
self._registration = registration
def send_code(
self,
*,
email: str,
language: str,
ip_address: str,
) -> str:
if self._security.is_ip_limited(ip_address):
raise ForgotPasswordSendIPLimitedError
normalized_email = email.lower()
account = self._accounts.find_by_email(email)
destination = account.email if account is not None else normalized_email
registration_allowed = self._registration.is_registration_allowed()
if self._send_limits.is_limited(destination):
raise ForgotPasswordSendRateLimitError(self._send_limits.retry_after_minutes)
code = self._codes.generate()
token = self._tokens.issue(
ForgotPasswordVerificationToken(
email=destination,
code=code,
account_id=account.id if account is not None else None,
)
)
self._notifications.send(
email=destination,
code=code,
language=language,
account_exists=account is not None,
registration_allowed=registration_allowed,
)
self._send_limits.record(destination)
return token
def verify_code(
self,
*,
email: str,
code: str,
token: str,
) -> ForgotPasswordVerification:
normalized_email = email.lower()
if self._security.is_verification_limited(normalized_email):
raise ForgotPasswordVerificationLimitError
token_data = self._tokens.read_verification(token)
if token_data is None:
raise InvalidForgotPasswordTokenError
normalized_token_email = token_data.email.lower()
if normalized_email != normalized_token_email:
raise InvalidForgotPasswordEmailError
if code != token_data.code:
self._security.record_verification_failure(normalized_email)
raise InvalidForgotPasswordCodeError
reset_token = self._tokens.promote(token, token_data.promote())
if reset_token is None:
raise InvalidForgotPasswordTokenError
self._security.reset_verification_failures(normalized_email)
return ForgotPasswordVerification(email=normalized_token_email, token=reset_token)
def reset(
self,
*,
token: str,
new_password: str,
password_confirm: str,
) -> None:
if new_password != password_confirm:
raise ForgotPasswordMismatchError
token_data = self._tokens.claim_reset(token)
if token_data is None:
raise InvalidForgotPasswordTokenError
if token_data.account_id is None:
account = self._accounts.find_by_email(token_data.email)
else:
account = self._accounts.get(token_data.account_id)
if account is None or account.email.lower() != token_data.email.lower():
raise AccountNotFoundError
password = self._passwords.hash(new_password)
if self._accounts.update_password(account.id, password) is None:
raise AccountNotFoundError