mirror of
https://github.com/langgenius/dify.git
synced 2026-10-16 15:35:32 +08:00
refactor(api): extract forgot password application service (#41111)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
autofix-ci[bot]
parent
8129555642
commit
be7d11ca1d
@@ -211,6 +211,7 @@ source_modules =
|
||||
services.account_deletion_feedback_service
|
||||
services.account_education_service
|
||||
services.account_errors
|
||||
services.account_forgot_password_service
|
||||
services.account_initialization_service
|
||||
services.account_integration_service
|
||||
services.account_password_service
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import base64
|
||||
import secrets
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -13,25 +10,23 @@ from controllers.console.auth.error import (
|
||||
InvalidEmailError,
|
||||
InvalidTokenError,
|
||||
PasswordMismatchError,
|
||||
PasswordResetRateLimitExceededError,
|
||||
)
|
||||
from controllers.console.error import AccountNotFound, EmailSendIpLimitError
|
||||
from controllers.console.wraps import email_password_login_enabled, model_validate, setup_required
|
||||
from extensions.ext_database import db
|
||||
from libs.helper import EmailStr, extract_remote_ip
|
||||
from libs.password import hash_password
|
||||
from services.account_service import AccountService, TenantService
|
||||
from extensions.ext_application_services import application_services
|
||||
from libs.helper import EmailStr, dump_response, extract_remote_ip
|
||||
from services import account_errors
|
||||
from services.entities.auth_entities import (
|
||||
ForgotPasswordCheckPayload,
|
||||
ForgotPasswordResetPayload,
|
||||
ForgotPasswordSendPayload,
|
||||
)
|
||||
from services.system_feature_service import SystemFeatureService
|
||||
|
||||
|
||||
class ForgotPasswordEmailResponse(BaseModel):
|
||||
result: str = Field(description="Operation result")
|
||||
data: str | None = Field(default=None, description="Reset token")
|
||||
code: str | None = Field(default=None, description="Error code if account not found")
|
||||
|
||||
|
||||
class ForgotPasswordCheckResponse(BaseModel):
|
||||
@@ -70,27 +65,20 @@ class ForgotPasswordSendEmailApi(Resource):
|
||||
@email_password_login_enabled
|
||||
@model_validate(ForgotPasswordSendPayload)
|
||||
def post(self, req_data: ForgotPasswordSendPayload):
|
||||
normalized_email = req_data.email.lower()
|
||||
|
||||
ip_address = extract_remote_ip(request)
|
||||
if AccountService.is_email_send_ip_limit(ip_address):
|
||||
raise EmailSendIpLimitError()
|
||||
language = "zh-Hans" if req_data.language == "zh-Hans" else "en-US"
|
||||
try:
|
||||
token = application_services().accounts.forgot_password.send_code(
|
||||
email=req_data.email,
|
||||
language=language,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
except account_errors.ForgotPasswordSendIPLimitedError:
|
||||
raise EmailSendIpLimitError() from None
|
||||
except account_errors.ForgotPasswordSendRateLimitError as error:
|
||||
raise PasswordResetRateLimitExceededError(error.retry_after_minutes) from None
|
||||
|
||||
if req_data.language is not None and req_data.language == "zh-Hans":
|
||||
language = "zh-Hans"
|
||||
else:
|
||||
language = "en-US"
|
||||
|
||||
account = AccountService.get_account_by_email_with_case_fallback(req_data.email, session=db.session())
|
||||
|
||||
token = AccountService.send_reset_password_email(
|
||||
account=account,
|
||||
email=normalized_email,
|
||||
language=language,
|
||||
is_allow_register=SystemFeatureService.is_registration_allowed(),
|
||||
)
|
||||
|
||||
return {"result": "success", "data": token}
|
||||
return dump_response(ForgotPasswordEmailResponse, {"result": "success", "data": token})
|
||||
|
||||
|
||||
@console_ns.route("/forgot-password/validity")
|
||||
@@ -108,40 +96,26 @@ class ForgotPasswordCheckApi(Resource):
|
||||
@email_password_login_enabled
|
||||
@model_validate(ForgotPasswordCheckPayload)
|
||||
def post(self, req_data: ForgotPasswordCheckPayload):
|
||||
try:
|
||||
verification = application_services().accounts.forgot_password.verify_code(
|
||||
email=req_data.email,
|
||||
code=req_data.code,
|
||||
token=req_data.token,
|
||||
)
|
||||
except account_errors.ForgotPasswordVerificationLimitError:
|
||||
raise EmailPasswordResetLimitError() from None
|
||||
except account_errors.InvalidForgotPasswordTokenError:
|
||||
raise InvalidTokenError() from None
|
||||
except account_errors.InvalidForgotPasswordEmailError:
|
||||
raise InvalidEmailError() from None
|
||||
except account_errors.InvalidForgotPasswordCodeError:
|
||||
raise EmailCodeError() from None
|
||||
|
||||
user_email = req_data.email.lower()
|
||||
|
||||
is_forgot_password_error_rate_limit = AccountService.is_forgot_password_error_rate_limit(user_email)
|
||||
if is_forgot_password_error_rate_limit:
|
||||
raise EmailPasswordResetLimitError()
|
||||
|
||||
token_data = AccountService.get_reset_password_data(req_data.token)
|
||||
if token_data is None:
|
||||
raise InvalidTokenError()
|
||||
|
||||
token_email = token_data.get("email")
|
||||
if not isinstance(token_email, str):
|
||||
raise InvalidEmailError()
|
||||
normalized_token_email = token_email.lower()
|
||||
|
||||
if user_email != normalized_token_email:
|
||||
raise InvalidEmailError()
|
||||
|
||||
if req_data.code != token_data.get("code"):
|
||||
AccountService.add_forgot_password_error_rate_limit(user_email)
|
||||
raise EmailCodeError()
|
||||
|
||||
# Verified, revoke the first token
|
||||
AccountService.revoke_reset_password_token(req_data.token)
|
||||
|
||||
# Refresh token data by generating a new token
|
||||
_, new_token = AccountService.generate_reset_password_token(
|
||||
token_email, code=req_data.code, additional_data={"phase": "reset"}
|
||||
return dump_response(
|
||||
ForgotPasswordCheckResponse,
|
||||
{"is_valid": True, "email": verification.email, "token": verification.token},
|
||||
)
|
||||
|
||||
AccountService.reset_forgot_password_error_rate_limit(user_email)
|
||||
return {"is_valid": True, "email": normalized_token_email, "token": new_token}
|
||||
|
||||
|
||||
@console_ns.route("/forgot-password/resets")
|
||||
class ForgotPasswordResetApi(Resource):
|
||||
@@ -158,46 +132,17 @@ class ForgotPasswordResetApi(Resource):
|
||||
@email_password_login_enabled
|
||||
@model_validate(ForgotPasswordResetPayload)
|
||||
def post(self, req_data: ForgotPasswordResetPayload):
|
||||
try:
|
||||
application_services().accounts.forgot_password.reset(
|
||||
token=req_data.token,
|
||||
new_password=req_data.new_password,
|
||||
password_confirm=req_data.password_confirm,
|
||||
)
|
||||
except account_errors.ForgotPasswordMismatchError:
|
||||
raise PasswordMismatchError() from None
|
||||
except account_errors.InvalidForgotPasswordTokenError:
|
||||
raise InvalidTokenError() from None
|
||||
except account_errors.AccountNotFoundError:
|
||||
raise AccountNotFound() from None
|
||||
|
||||
# Validate passwords match
|
||||
if req_data.new_password != req_data.password_confirm:
|
||||
raise PasswordMismatchError()
|
||||
|
||||
# Validate token and get reset data
|
||||
reset_data = AccountService.get_reset_password_data(req_data.token)
|
||||
if not reset_data:
|
||||
raise InvalidTokenError()
|
||||
# Must use token in reset phase
|
||||
if reset_data.get("phase", "") != "reset":
|
||||
raise InvalidTokenError()
|
||||
|
||||
# Revoke token to prevent reuse
|
||||
AccountService.revoke_reset_password_token(req_data.token)
|
||||
|
||||
# Generate secure salt and hash password
|
||||
salt = secrets.token_bytes(16)
|
||||
password_hashed = hash_password(req_data.new_password, salt)
|
||||
|
||||
email = reset_data.get("email", "")
|
||||
account = AccountService.get_account_by_email_with_case_fallback(email, session=db.session())
|
||||
|
||||
if account:
|
||||
account = db.session.merge(account)
|
||||
self._update_existing_account(account, password_hashed, salt)
|
||||
db.session.commit()
|
||||
else:
|
||||
raise AccountNotFound()
|
||||
|
||||
return {"result": "success"}
|
||||
|
||||
def _update_existing_account(self, account, password_hashed, salt):
|
||||
# Update existing account credentials
|
||||
account.password = base64.b64encode(password_hashed).decode()
|
||||
account.password_salt = base64.b64encode(salt).decode()
|
||||
|
||||
# Create workspace if needed
|
||||
if (
|
||||
not TenantService.get_join_tenants(account, session=db.session())
|
||||
and SystemFeatureService.is_workspace_creation_allowed()
|
||||
):
|
||||
TenantService.create_owner_tenant(account, session=db.session())
|
||||
return dump_response(ForgotPasswordResetResponse, {"result": "success"})
|
||||
|
||||
@@ -85,9 +85,18 @@ from services.account_email_registration_adapters import (
|
||||
TokenManagerEmailRegistrationTokenGateway,
|
||||
)
|
||||
from services.account_email_registration_service import AccountEmailRegistrationService
|
||||
from services.account_forgot_password_adapters import (
|
||||
CeleryForgotPasswordNotificationGateway,
|
||||
RateLimiterForgotPasswordSendLimiter,
|
||||
RedisForgotPasswordSecurityGateway,
|
||||
RedisForgotPasswordTokenGateway,
|
||||
SecureForgotPasswordCodeGenerator,
|
||||
SystemFeatureServiceForgotPasswordRegistrationPolicy,
|
||||
)
|
||||
from services.account_forgot_password_service import AccountForgotPasswordService
|
||||
from services.account_initialization_service import AccountInitializationService
|
||||
from services.account_integration_service import AccountIntegrationService
|
||||
from services.account_password_hasher import LegacyAccountPasswordHasher
|
||||
from services.account_password_hasher import DefaultAccountPasswordHasher
|
||||
from services.account_password_service import AccountPasswordService
|
||||
from services.account_profile_service import AccountProfileService
|
||||
from services.app_definition_query_service import AppDefinitionQueryService
|
||||
@@ -179,6 +188,7 @@ class AccountServices:
|
||||
deletion: AccountDeletionService
|
||||
deletion_feedback: AccountDeletionFeedbackService
|
||||
education: AccountEducationService
|
||||
forgot_password: AccountForgotPasswordService
|
||||
initialization: AccountInitializationService
|
||||
integrations: AccountIntegrationService
|
||||
password: AccountPasswordService
|
||||
@@ -280,6 +290,7 @@ def build_application_services(
|
||||
builtin=builtin_catalog,
|
||||
)
|
||||
workspace_query_repository = WorkspaceQueryRepository(session_factory=database_client)
|
||||
passwords = DefaultAccountPasswordHasher()
|
||||
return ApplicationServices(
|
||||
accounts=AccountServices(
|
||||
avatar=AccountAvatarService(
|
||||
@@ -365,6 +376,23 @@ def build_application_services(
|
||||
redis_client=redis,
|
||||
),
|
||||
),
|
||||
forgot_password=AccountForgotPasswordService(
|
||||
accounts=accounts,
|
||||
passwords=passwords,
|
||||
tokens=RedisForgotPasswordTokenGateway(
|
||||
redis=redis,
|
||||
expiry_seconds=int(dify_config.RESET_PASSWORD_TOKEN_EXPIRY_MINUTES * 60),
|
||||
),
|
||||
codes=SecureForgotPasswordCodeGenerator(),
|
||||
notifications=CeleryForgotPasswordNotificationGateway(),
|
||||
send_limits=RateLimiterForgotPasswordSendLimiter(redis=redis),
|
||||
security=RedisForgotPasswordSecurityGateway(
|
||||
redis=redis,
|
||||
email_send_ip_limit_per_minute=dify_config.EMAIL_SEND_IP_LIMIT_PER_MINUTE,
|
||||
verification_lockout_duration=dify_config.FORGOT_PASSWORD_LOCKOUT_DURATION,
|
||||
),
|
||||
registration=SystemFeatureServiceForgotPasswordRegistrationPolicy(),
|
||||
),
|
||||
initialization=AccountInitializationService(
|
||||
accounts=accounts,
|
||||
invitation_required=deployment_edition == DeploymentEdition.CLOUD,
|
||||
@@ -373,7 +401,7 @@ def build_application_services(
|
||||
integrations=AccountIntegrationService(integrations=integrations),
|
||||
password=AccountPasswordService(
|
||||
accounts=accounts,
|
||||
passwords=LegacyAccountPasswordHasher(),
|
||||
passwords=passwords,
|
||||
),
|
||||
profile=AccountProfileService(accounts=accounts),
|
||||
),
|
||||
|
||||
@@ -18395,7 +18395,6 @@ Enum class for fetch from.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| code | string | Error code if account not found | No |
|
||||
| data | string | Reset token | No |
|
||||
| result | string | Operation result | Yes |
|
||||
|
||||
|
||||
@@ -34,9 +34,9 @@ class SQLAlchemyAccountRepository(AccountRepository):
|
||||
@override
|
||||
def find_by_email(self, email: str) -> AccountSnapshot | None:
|
||||
with self._session_factory() as session:
|
||||
account = session.scalar(select(Account).where(Account.email == email).limit(1))
|
||||
account = session.execute(select(Account).where(Account.email == email)).scalar_one_or_none()
|
||||
if account is None and email != email.lower():
|
||||
account = session.scalar(select(Account).where(Account.email == email.lower()).limit(1))
|
||||
account = session.execute(select(Account).where(Account.email == email.lower())).scalar_one_or_none()
|
||||
return self._to_snapshot(account) if account is not None else None
|
||||
|
||||
@override
|
||||
|
||||
@@ -4,7 +4,6 @@ import secrets
|
||||
from typing import override
|
||||
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
from redis import RedisError
|
||||
|
||||
from extensions.ext_redis import RedisClientWrapper
|
||||
from libs.helper import RateLimiter, TokenManager
|
||||
@@ -16,6 +15,7 @@ from services.account_change_email_ports import (
|
||||
ChangeEmailSendLimiter,
|
||||
ChangeEmailTokenGateway,
|
||||
)
|
||||
from services.account_security_gateway import RedisAccountEmailSecurityGateway
|
||||
from services.billing_service import BillingService
|
||||
from services.entities.account_entities import (
|
||||
AccountChangeEmailNewEmailToken,
|
||||
@@ -115,7 +115,7 @@ class RateLimiterChangeEmailSendLimiter(ChangeEmailSendLimiter):
|
||||
return int(self._rate_limiter.time_window / 60)
|
||||
|
||||
|
||||
class RedisChangeEmailSecurityGateway(ChangeEmailSecurityGateway):
|
||||
class RedisChangeEmailSecurityGateway(RedisAccountEmailSecurityGateway, ChangeEmailSecurityGateway):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
@@ -124,63 +124,13 @@ class RedisChangeEmailSecurityGateway(ChangeEmailSecurityGateway):
|
||||
verification_failure_limit: int,
|
||||
verification_lockout_duration: int,
|
||||
) -> None:
|
||||
self._redis = redis
|
||||
self._email_send_ip_limit_per_minute = email_send_ip_limit_per_minute
|
||||
self._verification_failure_limit = verification_failure_limit
|
||||
self._verification_lockout_duration = verification_lockout_duration
|
||||
|
||||
@override
|
||||
def is_ip_limited(self, ip_address: str) -> bool:
|
||||
minute_key = f"email_send_ip_limit_minute:{ip_address}"
|
||||
freeze_key = f"email_send_ip_limit_freeze:{ip_address}"
|
||||
hour_limit_key = f"email_send_ip_limit_hour:{ip_address}"
|
||||
try:
|
||||
if self._redis.get(freeze_key):
|
||||
return True
|
||||
|
||||
current_minute_count = int(self._redis.get(minute_key) or 0)
|
||||
if current_minute_count > self._email_send_ip_limit_per_minute:
|
||||
hour_limit_count = int(self._redis.get(hour_limit_key) or 0)
|
||||
if hour_limit_count >= 1:
|
||||
self._redis.setex(freeze_key, 60 * 60, 1)
|
||||
return True
|
||||
if not self._redis.set(hour_limit_key, 1, ex=60 * 10, nx=True):
|
||||
self._redis.setex(freeze_key, 60 * 60, 1)
|
||||
return True
|
||||
|
||||
self._redis.setex(minute_key, 60, current_minute_count + 1)
|
||||
self._redis.expire(minute_key, 60)
|
||||
return False
|
||||
except RedisError:
|
||||
return False
|
||||
|
||||
@override
|
||||
def is_verification_limited(self, email: str) -> bool:
|
||||
try:
|
||||
count = self._redis.get(self._verification_key(email))
|
||||
return count is not None and int(count) > self._verification_failure_limit
|
||||
except RedisError:
|
||||
return False
|
||||
|
||||
@override
|
||||
def record_verification_failure(self, email: str) -> None:
|
||||
try:
|
||||
key = self._verification_key(email)
|
||||
count = int(self._redis.get(key) or 0) + 1
|
||||
self._redis.setex(key, self._verification_lockout_duration, count)
|
||||
except RedisError:
|
||||
return None
|
||||
|
||||
@override
|
||||
def reset_verification_failures(self, email: str) -> None:
|
||||
try:
|
||||
self._redis.delete(self._verification_key(email))
|
||||
except RedisError:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _verification_key(email: str) -> str:
|
||||
return f"change_email_error_rate_limit:{email}"
|
||||
super().__init__(
|
||||
redis=redis,
|
||||
email_send_ip_limit_per_minute=email_send_ip_limit_per_minute,
|
||||
verification_failure_limit=verification_failure_limit,
|
||||
verification_lockout_duration=verification_lockout_duration,
|
||||
verification_key_prefix="change_email_error_rate_limit",
|
||||
)
|
||||
|
||||
|
||||
class BillingAccountEmailPolicyGateway(AccountEmailPolicyGateway):
|
||||
|
||||
@@ -17,6 +17,38 @@ class InvalidAccountPasswordError(AccountApplicationError):
|
||||
"""The requested password does not satisfy the account password policy."""
|
||||
|
||||
|
||||
class ForgotPasswordSendIPLimitedError(AccountApplicationError):
|
||||
"""The caller IP exceeded the email-send policy."""
|
||||
|
||||
|
||||
class ForgotPasswordSendRateLimitError(AccountApplicationError):
|
||||
"""Too many password-reset messages were requested for the address."""
|
||||
|
||||
def __init__(self, retry_after_minutes: int) -> None:
|
||||
super().__init__(retry_after_minutes)
|
||||
self.retry_after_minutes = retry_after_minutes
|
||||
|
||||
|
||||
class ForgotPasswordVerificationLimitError(AccountApplicationError):
|
||||
"""Too many invalid password-reset codes were submitted."""
|
||||
|
||||
|
||||
class InvalidForgotPasswordTokenError(AccountApplicationError):
|
||||
"""The password-reset token is absent, malformed, or in the wrong phase."""
|
||||
|
||||
|
||||
class InvalidForgotPasswordEmailError(AccountApplicationError):
|
||||
"""The request address does not match the password-reset token."""
|
||||
|
||||
|
||||
class InvalidForgotPasswordCodeError(AccountApplicationError):
|
||||
"""The verification code does not match the password-reset token."""
|
||||
|
||||
|
||||
class ForgotPasswordMismatchError(AccountApplicationError):
|
||||
"""The password and its confirmation do not match."""
|
||||
|
||||
|
||||
class AvatarFileNotFoundError(AccountApplicationError):
|
||||
"""The requested avatar file does not exist or is not owned by the account."""
|
||||
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
"""Infrastructure adapters for the account forgot-password application service."""
|
||||
|
||||
import secrets
|
||||
import uuid
|
||||
from typing import override
|
||||
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from extensions.ext_redis import RedisClientWrapper
|
||||
from extensions.redis_names import serialize_redis_name
|
||||
from libs.helper import RateLimiter
|
||||
from services.account_forgot_password_service import (
|
||||
FORGOT_PASSWORD_SEND_RATE_LIMIT_MAX_ATTEMPTS,
|
||||
FORGOT_PASSWORD_SEND_RATE_LIMIT_PREFIX,
|
||||
FORGOT_PASSWORD_SEND_RATE_LIMIT_WINDOW_SECONDS,
|
||||
FORGOT_PASSWORD_VERIFICATION_FAILURE_LIMIT,
|
||||
FORGOT_PASSWORD_VERIFICATION_KEY_PREFIX,
|
||||
ForgotPasswordCodeGenerator,
|
||||
ForgotPasswordNotificationGateway,
|
||||
ForgotPasswordRegistrationPolicy,
|
||||
ForgotPasswordSecurityGateway,
|
||||
ForgotPasswordSendLimiter,
|
||||
ForgotPasswordTokenGateway,
|
||||
)
|
||||
from services.account_security_gateway import RedisAccountEmailSecurityGateway
|
||||
from services.entities.account_entities import (
|
||||
ForgotPasswordResetToken,
|
||||
ForgotPasswordToken,
|
||||
ForgotPasswordVerificationToken,
|
||||
)
|
||||
from services.entities.auth_entities import (
|
||||
ForgotPasswordResetTokenData,
|
||||
ForgotPasswordTokenData,
|
||||
ForgotPasswordVerificationTokenData,
|
||||
)
|
||||
from services.system_feature_service import SystemFeatureService
|
||||
from tasks.mail_reset_password_task import (
|
||||
send_reset_password_mail_task,
|
||||
send_reset_password_mail_task_when_account_not_exist,
|
||||
)
|
||||
|
||||
_RESET_PASSWORD_TOKEN_TYPE = "reset_password"
|
||||
_token_data_adapter: TypeAdapter[ForgotPasswordTokenData] = TypeAdapter(ForgotPasswordTokenData)
|
||||
_COMPARE_AND_SET_CURRENT_TOKEN_LUA = """
|
||||
local current = redis.call('GET', KEYS[1])
|
||||
if current ~= ARGV[1] then
|
||||
return 0
|
||||
end
|
||||
redis.call('SET', KEYS[1], ARGV[2], 'EX', ARGV[3])
|
||||
return 1
|
||||
"""
|
||||
|
||||
|
||||
class RedisForgotPasswordTokenGateway(ForgotPasswordTokenGateway):
|
||||
def __init__(self, *, redis: RedisClientWrapper, expiry_seconds: int) -> None:
|
||||
self._redis = redis
|
||||
self._expiry_seconds = expiry_seconds
|
||||
|
||||
@override
|
||||
def read_verification(self, token: str) -> ForgotPasswordVerificationToken | None:
|
||||
key = f"{_RESET_PASSWORD_TOKEN_TYPE}:token:{token}"
|
||||
raw_payload = self._redis.get(key)
|
||||
if raw_payload is None:
|
||||
return None
|
||||
|
||||
token_data = self._deserialize(raw_payload) if isinstance(raw_payload, str | bytes | bytearray) else None
|
||||
if not isinstance(token_data, ForgotPasswordVerificationToken):
|
||||
return None
|
||||
if token_data.account_id is not None:
|
||||
if not self._is_current(token_data.account_id, token):
|
||||
return None
|
||||
return token_data
|
||||
|
||||
@override
|
||||
def claim_reset(self, token: str) -> ForgotPasswordResetToken | None:
|
||||
key = f"{_RESET_PASSWORD_TOKEN_TYPE}:token:{token}"
|
||||
raw_payload = self._redis.getdel(key)
|
||||
if raw_payload is None:
|
||||
return None
|
||||
|
||||
token_data = self._deserialize(raw_payload) if isinstance(raw_payload, str | bytes | bytearray) else None
|
||||
if not isinstance(token_data, ForgotPasswordResetToken):
|
||||
return None
|
||||
if token_data.account_id is not None and not self._is_current(token_data.account_id, token):
|
||||
return None
|
||||
return token_data
|
||||
|
||||
@staticmethod
|
||||
def _deserialize(raw_payload: str | bytes | bytearray) -> ForgotPasswordToken | None:
|
||||
try:
|
||||
payload = _token_data_adapter.validate_json(raw_payload)
|
||||
except ValidationError:
|
||||
return None
|
||||
if isinstance(payload, ForgotPasswordResetTokenData):
|
||||
return ForgotPasswordResetToken(
|
||||
email=str(payload.email),
|
||||
code=payload.code,
|
||||
account_id=payload.account_id,
|
||||
)
|
||||
return ForgotPasswordVerificationToken(
|
||||
email=str(payload.email),
|
||||
code=payload.code,
|
||||
account_id=payload.account_id,
|
||||
)
|
||||
|
||||
@override
|
||||
def issue(self, token_data: ForgotPasswordToken) -> str:
|
||||
token = self._create_token(token_data)
|
||||
account_id = token_data.account_id
|
||||
if account_id is None:
|
||||
return token
|
||||
|
||||
account_key = f"{_RESET_PASSWORD_TOKEN_TYPE}:account:{account_id}"
|
||||
previous_token = self._redis.get(account_key)
|
||||
self._redis.setex(account_key, self._expiry_seconds, token)
|
||||
if isinstance(previous_token, bytes):
|
||||
previous_token = previous_token.decode()
|
||||
if isinstance(previous_token, str) and previous_token != token:
|
||||
self._redis.delete(f"{_RESET_PASSWORD_TOKEN_TYPE}:token:{previous_token}")
|
||||
return token
|
||||
|
||||
@override
|
||||
def promote(self, claimed_token: str, token_data: ForgotPasswordResetToken) -> str | None:
|
||||
account_id = token_data.account_id
|
||||
if account_id is None:
|
||||
key = f"{_RESET_PASSWORD_TOKEN_TYPE}:token:{claimed_token}"
|
||||
raw_payload = self._redis.getdel(key)
|
||||
claimed = self._deserialize(raw_payload) if isinstance(raw_payload, str | bytes | bytearray) else None
|
||||
if not isinstance(claimed, ForgotPasswordVerificationToken) or claimed.promote() != token_data:
|
||||
return None
|
||||
return self.issue(token_data)
|
||||
|
||||
reset_token = self._create_token(token_data)
|
||||
promoted = self._compare_and_set_current(
|
||||
account_id=account_id,
|
||||
expected=claimed_token,
|
||||
replacement=reset_token,
|
||||
expiry_seconds=self._expiry_seconds,
|
||||
)
|
||||
if promoted:
|
||||
self._redis.delete(f"{_RESET_PASSWORD_TOKEN_TYPE}:token:{claimed_token}")
|
||||
return reset_token
|
||||
|
||||
self._redis.delete(f"{_RESET_PASSWORD_TOKEN_TYPE}:token:{reset_token}")
|
||||
return None
|
||||
|
||||
def _create_token(self, token_data: ForgotPasswordToken) -> str:
|
||||
payload: ForgotPasswordTokenData
|
||||
if isinstance(token_data, ForgotPasswordResetToken):
|
||||
payload = ForgotPasswordResetTokenData(
|
||||
account_id=token_data.account_id,
|
||||
email=token_data.email,
|
||||
code=token_data.code,
|
||||
phase="reset",
|
||||
)
|
||||
else:
|
||||
payload = ForgotPasswordVerificationTokenData(
|
||||
account_id=token_data.account_id,
|
||||
email=token_data.email,
|
||||
code=token_data.code,
|
||||
)
|
||||
|
||||
token = str(uuid.uuid4())
|
||||
self._redis.setex(
|
||||
f"{_RESET_PASSWORD_TOKEN_TYPE}:token:{token}",
|
||||
self._expiry_seconds,
|
||||
payload.model_dump_json(),
|
||||
)
|
||||
return token
|
||||
|
||||
def _compare_and_set_current(
|
||||
self,
|
||||
*,
|
||||
account_id: str,
|
||||
expected: str,
|
||||
replacement: str,
|
||||
expiry_seconds: int,
|
||||
) -> bool:
|
||||
account_key = f"{_RESET_PASSWORD_TOKEN_TYPE}:account:{account_id}"
|
||||
return bool(
|
||||
self._redis.eval(
|
||||
_COMPARE_AND_SET_CURRENT_TOKEN_LUA,
|
||||
1,
|
||||
serialize_redis_name(account_key),
|
||||
expected,
|
||||
replacement,
|
||||
expiry_seconds,
|
||||
)
|
||||
)
|
||||
|
||||
def _is_current(self, account_id: str, token: str) -> bool:
|
||||
current = self._redis.get(f"{_RESET_PASSWORD_TOKEN_TYPE}:account:{account_id}")
|
||||
if isinstance(current, bytes):
|
||||
current = current.decode()
|
||||
return current == token
|
||||
|
||||
|
||||
class SecureForgotPasswordCodeGenerator(ForgotPasswordCodeGenerator):
|
||||
@override
|
||||
def generate(self) -> str:
|
||||
return "".join(str(secrets.randbelow(exclusive_upper_bound=10)) for _ in range(6))
|
||||
|
||||
|
||||
class CeleryForgotPasswordNotificationGateway(ForgotPasswordNotificationGateway):
|
||||
@override
|
||||
def send(
|
||||
self,
|
||||
*,
|
||||
email: str,
|
||||
code: str,
|
||||
language: str,
|
||||
account_exists: bool,
|
||||
registration_allowed: bool,
|
||||
) -> None:
|
||||
if account_exists:
|
||||
send_reset_password_mail_task.delay(language=language, to=email, code=code)
|
||||
return
|
||||
send_reset_password_mail_task_when_account_not_exist.delay(
|
||||
language=language,
|
||||
to=email,
|
||||
is_allow_register=registration_allowed,
|
||||
)
|
||||
|
||||
|
||||
class RateLimiterForgotPasswordSendLimiter(ForgotPasswordSendLimiter):
|
||||
def __init__(self, *, redis: RedisClientWrapper) -> None:
|
||||
self._rate_limiter = RateLimiter(
|
||||
prefix=FORGOT_PASSWORD_SEND_RATE_LIMIT_PREFIX,
|
||||
max_attempts=FORGOT_PASSWORD_SEND_RATE_LIMIT_MAX_ATTEMPTS,
|
||||
time_window=FORGOT_PASSWORD_SEND_RATE_LIMIT_WINDOW_SECONDS,
|
||||
redis_client=redis,
|
||||
)
|
||||
|
||||
@override
|
||||
def is_limited(self, email: str) -> bool:
|
||||
return self._rate_limiter.is_rate_limited(email)
|
||||
|
||||
@override
|
||||
def record(self, email: str) -> None:
|
||||
self._rate_limiter.increment_rate_limit(email)
|
||||
|
||||
@property
|
||||
@override
|
||||
def retry_after_minutes(self) -> int:
|
||||
return int(self._rate_limiter.time_window / 60)
|
||||
|
||||
|
||||
class RedisForgotPasswordSecurityGateway(RedisAccountEmailSecurityGateway, ForgotPasswordSecurityGateway):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
redis: RedisClientWrapper,
|
||||
email_send_ip_limit_per_minute: int,
|
||||
verification_lockout_duration: int,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
redis=redis,
|
||||
email_send_ip_limit_per_minute=email_send_ip_limit_per_minute,
|
||||
verification_failure_limit=FORGOT_PASSWORD_VERIFICATION_FAILURE_LIMIT,
|
||||
verification_lockout_duration=verification_lockout_duration,
|
||||
verification_key_prefix=FORGOT_PASSWORD_VERIFICATION_KEY_PREFIX,
|
||||
)
|
||||
|
||||
|
||||
class SystemFeatureServiceForgotPasswordRegistrationPolicy(ForgotPasswordRegistrationPolicy):
|
||||
@override
|
||||
def is_registration_allowed(self) -> bool:
|
||||
return SystemFeatureService.is_registration_allowed()
|
||||
@@ -0,0 +1,186 @@
|
||||
"""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
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Legacy password implementation behind the account password port."""
|
||||
"""Default password implementation behind the account password port."""
|
||||
|
||||
import base64
|
||||
import secrets
|
||||
@@ -10,7 +10,7 @@ from services.account_ports import AccountPasswordHasher
|
||||
from services.entities.account_entities import AccountPasswordDigest
|
||||
|
||||
|
||||
class LegacyAccountPasswordHasher(AccountPasswordHasher):
|
||||
class DefaultAccountPasswordHasher(AccountPasswordHasher):
|
||||
@override
|
||||
def verify(self, password: str, *, password_hash: str, password_salt: str) -> bool:
|
||||
return compare_password(password, password_hash, password_salt)
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Shared Redis-backed security policies for account email flows."""
|
||||
|
||||
import logging
|
||||
|
||||
from redis import RedisError
|
||||
|
||||
from extensions.ext_redis import RedisClientWrapper
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RedisAccountEmailSecurityGateway:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
redis: RedisClientWrapper,
|
||||
email_send_ip_limit_per_minute: int,
|
||||
verification_failure_limit: int,
|
||||
verification_lockout_duration: int,
|
||||
verification_key_prefix: str,
|
||||
) -> None:
|
||||
self._redis = redis
|
||||
self._email_send_ip_limit_per_minute = email_send_ip_limit_per_minute
|
||||
self._verification_failure_limit = verification_failure_limit
|
||||
self._verification_lockout_duration = verification_lockout_duration
|
||||
self._verification_key_prefix = verification_key_prefix
|
||||
|
||||
def is_ip_limited(self, ip_address: str) -> bool:
|
||||
minute_key = f"email_send_ip_limit_minute:{ip_address}"
|
||||
freeze_key = f"email_send_ip_limit_freeze:{ip_address}"
|
||||
hour_limit_key = f"email_send_ip_limit_hour:{ip_address}"
|
||||
try:
|
||||
if self._redis.get(freeze_key):
|
||||
return True
|
||||
|
||||
current_minute_count = int(self._redis.get(minute_key) or 0)
|
||||
if current_minute_count > self._email_send_ip_limit_per_minute:
|
||||
hour_limit_count = int(self._redis.get(hour_limit_key) or 0)
|
||||
if hour_limit_count >= 1:
|
||||
self._redis.setex(freeze_key, 60 * 60, 1)
|
||||
return True
|
||||
if not self._redis.set(hour_limit_key, 1, ex=60 * 10, nx=True):
|
||||
self._redis.setex(freeze_key, 60 * 60, 1)
|
||||
return True
|
||||
|
||||
self._redis.setex(minute_key, 60, current_minute_count + 1)
|
||||
self._redis.expire(minute_key, 60)
|
||||
return False
|
||||
except RedisError:
|
||||
logger.warning("Redis unavailable while checking the account email IP limit", exc_info=True)
|
||||
return False
|
||||
|
||||
def is_verification_limited(self, email: str) -> bool:
|
||||
try:
|
||||
count = self._redis.get(self._verification_key(email))
|
||||
return count is not None and int(count) > self._verification_failure_limit
|
||||
except RedisError:
|
||||
logger.warning("Redis unavailable while checking the account email verification limit", exc_info=True)
|
||||
return False
|
||||
|
||||
def record_verification_failure(self, email: str) -> None:
|
||||
try:
|
||||
key = self._verification_key(email)
|
||||
count = int(self._redis.get(key) or 0) + 1
|
||||
self._redis.setex(key, self._verification_lockout_duration, count)
|
||||
except RedisError:
|
||||
logger.warning("Redis unavailable while recording an account email verification failure", exc_info=True)
|
||||
return None
|
||||
|
||||
def reset_verification_failures(self, email: str) -> None:
|
||||
try:
|
||||
self._redis.delete(self._verification_key(email))
|
||||
except RedisError:
|
||||
logger.warning("Redis unavailable while resetting account email verification failures", exc_info=True)
|
||||
return None
|
||||
|
||||
def _verification_key(self, email: str) -> str:
|
||||
return f"{self._verification_key_prefix}:{email}"
|
||||
@@ -48,6 +48,13 @@ from models.account import (
|
||||
from models.dataset import Dataset
|
||||
from models.model import App, DifySetup
|
||||
from services.account_email import normalize_email
|
||||
from services.account_forgot_password_service import (
|
||||
FORGOT_PASSWORD_SEND_RATE_LIMIT_MAX_ATTEMPTS,
|
||||
FORGOT_PASSWORD_SEND_RATE_LIMIT_PREFIX,
|
||||
FORGOT_PASSWORD_SEND_RATE_LIMIT_WINDOW_SECONDS,
|
||||
FORGOT_PASSWORD_VERIFICATION_FAILURE_LIMIT,
|
||||
FORGOT_PASSWORD_VERIFICATION_KEY_PREFIX,
|
||||
)
|
||||
from services.billing_service import BillingService
|
||||
from services.email_code_login_challenge import (
|
||||
EmailCodeLoginChallengeResult,
|
||||
@@ -155,7 +162,11 @@ class AccountService:
|
||||
CHANGE_EMAIL_PHASE_OLD = ChangeEmailPhase.OLD_EMAIL
|
||||
CHANGE_EMAIL_PHASE_NEW = ChangeEmailPhase.NEW_EMAIL
|
||||
|
||||
reset_password_rate_limiter = RateLimiter(prefix="reset_password_rate_limit", max_attempts=1, time_window=60 * 1)
|
||||
reset_password_rate_limiter = RateLimiter(
|
||||
prefix=FORGOT_PASSWORD_SEND_RATE_LIMIT_PREFIX,
|
||||
max_attempts=FORGOT_PASSWORD_SEND_RATE_LIMIT_MAX_ATTEMPTS,
|
||||
time_window=FORGOT_PASSWORD_SEND_RATE_LIMIT_WINDOW_SECONDS,
|
||||
)
|
||||
email_code_login_rate_limiter = RateLimiter(
|
||||
prefix="email_code_login_rate_limit", max_attempts=3, time_window=300 * 1
|
||||
)
|
||||
@@ -163,7 +174,6 @@ class AccountService:
|
||||
owner_transfer_rate_limiter = RateLimiter(prefix="owner_transfer_rate_limit", max_attempts=1, time_window=60 * 1)
|
||||
|
||||
LOGIN_MAX_ERROR_LIMITS = 5
|
||||
FORGOT_PASSWORD_MAX_ERROR_LIMITS = 5
|
||||
CHANGE_EMAIL_MAX_ERROR_LIMITS = 5
|
||||
OWNER_TRANSFER_MAX_ERROR_LIMITS = 5
|
||||
|
||||
@@ -1001,7 +1011,7 @@ class AccountService:
|
||||
@staticmethod
|
||||
@redis_fallback(default_return=None)
|
||||
def add_forgot_password_error_rate_limit(email: str):
|
||||
key = f"forgot_password_error_rate_limit:{email}"
|
||||
key = f"{FORGOT_PASSWORD_VERIFICATION_KEY_PREFIX}:{email}"
|
||||
count = redis_client.get(key)
|
||||
if count is None:
|
||||
count = 0
|
||||
@@ -1011,20 +1021,20 @@ class AccountService:
|
||||
@staticmethod
|
||||
@redis_fallback(default_return=False)
|
||||
def is_forgot_password_error_rate_limit(email: str) -> bool:
|
||||
key = f"forgot_password_error_rate_limit:{email}"
|
||||
key = f"{FORGOT_PASSWORD_VERIFICATION_KEY_PREFIX}:{email}"
|
||||
count = redis_client.get(key)
|
||||
if count is None:
|
||||
return False
|
||||
|
||||
count = int(count)
|
||||
if count > AccountService.FORGOT_PASSWORD_MAX_ERROR_LIMITS:
|
||||
if count > FORGOT_PASSWORD_VERIFICATION_FAILURE_LIMIT:
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
@redis_fallback(default_return=None)
|
||||
def reset_forgot_password_error_rate_limit(email: str):
|
||||
key = f"forgot_password_error_rate_limit:{email}"
|
||||
key = f"{FORGOT_PASSWORD_VERIFICATION_KEY_PREFIX}:{email}"
|
||||
redis_client.delete(key)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -57,6 +57,32 @@ class AccountPasswordDigest:
|
||||
password_salt: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ForgotPasswordVerificationToken:
|
||||
email: str
|
||||
code: str
|
||||
account_id: str | None = None
|
||||
|
||||
def promote(self) -> ForgotPasswordResetToken:
|
||||
return ForgotPasswordResetToken(email=self.email, code=self.code, account_id=self.account_id)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ForgotPasswordResetToken:
|
||||
email: str
|
||||
code: str
|
||||
account_id: str | None = None
|
||||
|
||||
|
||||
type ForgotPasswordToken = ForgotPasswordVerificationToken | ForgotPasswordResetToken
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ForgotPasswordVerification:
|
||||
email: str
|
||||
token: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AccountIntegrationSnapshot:
|
||||
provider: str
|
||||
|
||||
@@ -66,6 +66,26 @@ class ForgotPasswordResetPayload(BaseModel):
|
||||
return valid_password(value)
|
||||
|
||||
|
||||
class ForgotPasswordTokenBase(BaseModel):
|
||||
token_type: Literal["reset_password"] = "reset_password"
|
||||
account_id: str | None = None
|
||||
email: EmailStr
|
||||
code: str = Field(min_length=1)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class ForgotPasswordVerificationTokenData(ForgotPasswordTokenBase):
|
||||
pass
|
||||
|
||||
|
||||
class ForgotPasswordResetTokenData(ForgotPasswordTokenBase):
|
||||
phase: Literal["reset"]
|
||||
|
||||
|
||||
type ForgotPasswordTokenData = ForgotPasswordResetTokenData | ForgotPasswordVerificationTokenData
|
||||
|
||||
|
||||
class ChangeEmailTokenBase(BaseModel):
|
||||
"""Stored change-email token payload.
|
||||
|
||||
|
||||
@@ -1,184 +1,235 @@
|
||||
"""Unit tests for forgot password controller endpoints."""
|
||||
"""Transport-boundary tests for Console forgot-password endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from unittest.mock import patch
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from controllers.console.auth.forgot_password import (
|
||||
ForgotPasswordCheckApi,
|
||||
ForgotPasswordResetApi,
|
||||
ForgotPasswordSendEmailApi,
|
||||
from controllers.console.auth import forgot_password
|
||||
from controllers.console.auth.error import (
|
||||
EmailCodeError,
|
||||
EmailPasswordResetLimitError,
|
||||
InvalidEmailError,
|
||||
InvalidTokenError,
|
||||
PasswordMismatchError,
|
||||
PasswordResetRateLimitExceededError,
|
||||
)
|
||||
from controllers.console.error import AccountNotFound, EmailSendIpLimitError
|
||||
from enums import DeploymentEdition
|
||||
from models.account import Account
|
||||
from models.engine import db
|
||||
from services.entities.feature_entities import SystemFeatureModel
|
||||
from tests.unit_tests.config_override import config_overrides_context
|
||||
from services import account_errors
|
||||
from services.entities.account_entities import ForgotPasswordVerification
|
||||
from tests.unit_tests.config_override import apply_config_overrides
|
||||
|
||||
|
||||
class FakeForgotPasswordService:
|
||||
def __init__(self) -> None:
|
||||
self.error: Exception | None = None
|
||||
self.send_arguments: dict[str, str] | None = None
|
||||
self.verify_arguments: dict[str, str] | None = None
|
||||
self.reset_arguments: dict[str, str] | None = None
|
||||
|
||||
def _raise_if_needed(self) -> None:
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
|
||||
def send_code(
|
||||
self,
|
||||
*,
|
||||
email: str,
|
||||
language: str,
|
||||
ip_address: str,
|
||||
) -> str:
|
||||
self.send_arguments = {"email": email, "language": language, "ip_address": ip_address}
|
||||
self._raise_if_needed()
|
||||
return "reset-token"
|
||||
|
||||
def verify_code(
|
||||
self,
|
||||
*,
|
||||
email: str,
|
||||
code: str,
|
||||
token: str,
|
||||
) -> ForgotPasswordVerification:
|
||||
self.verify_arguments = {"email": email, "code": code, "token": token}
|
||||
self._raise_if_needed()
|
||||
return ForgotPasswordVerification(email="user@example.com", token="promoted-token")
|
||||
|
||||
def reset(
|
||||
self,
|
||||
*,
|
||||
token: str,
|
||||
new_password: str,
|
||||
password_confirm: str,
|
||||
) -> None:
|
||||
self.reset_arguments = {
|
||||
"token": token,
|
||||
"new_password": new_password,
|
||||
"password_confirm": password_confirm,
|
||||
}
|
||||
self._raise_if_needed()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def admit_forgot_password_requests(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
apply_config_overrides(
|
||||
monkeypatch,
|
||||
DEPLOYMENT_EDITION=DeploymentEdition.CLOUD,
|
||||
ENABLE_EMAIL_PASSWORD_LOGIN=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def database_app() -> Iterator[Flask]:
|
||||
app = Flask(__name__)
|
||||
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
|
||||
db.init_app(app)
|
||||
|
||||
with app.app_context():
|
||||
Account.__table__.create(db.engine)
|
||||
yield app
|
||||
def service(monkeypatch: pytest.MonkeyPatch) -> FakeForgotPasswordService:
|
||||
service = FakeForgotPasswordService()
|
||||
services = SimpleNamespace(accounts=SimpleNamespace(forgot_password=service))
|
||||
monkeypatch.setattr(forgot_password, "application_services", lambda: services)
|
||||
monkeypatch.setattr(forgot_password, "extract_remote_ip", lambda _request: "127.0.0.1")
|
||||
return service
|
||||
|
||||
|
||||
class TestForgotPasswordSendEmailApi:
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.get_account_by_email_with_case_fallback")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.send_reset_password_email")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.is_email_send_ip_limit", return_value=False)
|
||||
@patch("controllers.console.auth.forgot_password.extract_remote_ip", return_value="127.0.0.1")
|
||||
def test_send_normalizes_email(
|
||||
self,
|
||||
mock_extract_ip,
|
||||
mock_is_ip_limit,
|
||||
mock_send_email,
|
||||
mock_get_account,
|
||||
app: Flask,
|
||||
def test_send_parses_input_and_serializes_service_result(app: Flask, service: FakeForgotPasswordService) -> None:
|
||||
with app.test_request_context(
|
||||
"/forgot-password",
|
||||
method="POST",
|
||||
json={"email": "User@Example.com", "language": "zh-Hans"},
|
||||
):
|
||||
account = Account(name="User", email="user@example.com")
|
||||
mock_get_account.return_value = account
|
||||
mock_send_email.return_value = "token-123"
|
||||
response = forgot_password.ForgotPasswordSendEmailApi().post()
|
||||
|
||||
wraps_features = SystemFeatureModel(
|
||||
deployment_edition=DeploymentEdition.COMMUNITY,
|
||||
enable_email_password_login=True,
|
||||
is_allow_register=True,
|
||||
)
|
||||
controller_features = SystemFeatureModel(
|
||||
deployment_edition=DeploymentEdition.COMMUNITY,
|
||||
is_allow_register=True,
|
||||
)
|
||||
with (
|
||||
patch(
|
||||
"controllers.console.auth.forgot_password.SystemFeatureService.is_registration_allowed",
|
||||
return_value=controller_features.is_allow_register,
|
||||
),
|
||||
config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD),
|
||||
patch(
|
||||
"controllers.console.wraps.SystemFeatureService.is_email_password_login_enabled",
|
||||
return_value=wraps_features.enable_email_password_login,
|
||||
),
|
||||
):
|
||||
with app.test_request_context(
|
||||
"/forgot-password",
|
||||
method="POST",
|
||||
json={"email": "User@Example.com", "language": "zh-Hans"},
|
||||
):
|
||||
response = ForgotPasswordSendEmailApi().post()
|
||||
|
||||
assert response == {"result": "success", "data": "token-123"}
|
||||
mock_send_email.assert_called_once_with(
|
||||
account=account,
|
||||
email="user@example.com",
|
||||
language="zh-Hans",
|
||||
is_allow_register=True,
|
||||
)
|
||||
mock_is_ip_limit.assert_called_once_with("127.0.0.1")
|
||||
mock_extract_ip.assert_called_once()
|
||||
assert response == {"result": "success", "data": "reset-token"}
|
||||
assert service.send_arguments == {
|
||||
"email": "User@Example.com",
|
||||
"language": "zh-Hans",
|
||||
"ip_address": "127.0.0.1",
|
||||
}
|
||||
|
||||
|
||||
class TestForgotPasswordCheckApi:
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.reset_forgot_password_error_rate_limit")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.generate_reset_password_token")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.revoke_reset_password_token")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.add_forgot_password_error_rate_limit")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.get_reset_password_data")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.is_forgot_password_error_rate_limit")
|
||||
def test_check_normalizes_email(
|
||||
self,
|
||||
mock_rate_limit_check,
|
||||
mock_get_data,
|
||||
mock_add_rate,
|
||||
mock_revoke_token,
|
||||
mock_generate_token,
|
||||
mock_reset_rate,
|
||||
app: Flask,
|
||||
def test_send_defaults_unsupported_language(app: Flask, service: FakeForgotPasswordService) -> None:
|
||||
with app.test_request_context(
|
||||
"/forgot-password",
|
||||
method="POST",
|
||||
json={"email": "user@example.com", "language": "fr-FR"},
|
||||
):
|
||||
mock_rate_limit_check.return_value = False
|
||||
mock_get_data.return_value = {"email": "Admin@Example.com", "code": "4321"}
|
||||
mock_generate_token.return_value = (None, "new-token")
|
||||
forgot_password.ForgotPasswordSendEmailApi().post()
|
||||
|
||||
wraps_features = SystemFeatureModel(
|
||||
deployment_edition=DeploymentEdition.COMMUNITY,
|
||||
enable_email_password_login=True,
|
||||
)
|
||||
with (
|
||||
config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD),
|
||||
patch(
|
||||
"controllers.console.wraps.SystemFeatureService.is_email_password_login_enabled",
|
||||
return_value=wraps_features.enable_email_password_login,
|
||||
),
|
||||
):
|
||||
with app.test_request_context(
|
||||
"/forgot-password/validity",
|
||||
method="POST",
|
||||
json={"email": "ADMIN@Example.com", "code": "4321", "token": "token-123"},
|
||||
):
|
||||
response = ForgotPasswordCheckApi().post()
|
||||
|
||||
assert response == {"is_valid": True, "email": "admin@example.com", "token": "new-token"}
|
||||
mock_rate_limit_check.assert_called_once_with("admin@example.com")
|
||||
mock_generate_token.assert_called_once_with(
|
||||
"Admin@Example.com",
|
||||
code="4321",
|
||||
additional_data={"phase": "reset"},
|
||||
)
|
||||
mock_reset_rate.assert_called_once_with("admin@example.com")
|
||||
mock_add_rate.assert_not_called()
|
||||
mock_revoke_token.assert_called_once_with("token-123")
|
||||
assert service.send_arguments is not None
|
||||
assert service.send_arguments["language"] == "en-US"
|
||||
|
||||
|
||||
class TestForgotPasswordResetApi:
|
||||
@patch("controllers.console.auth.forgot_password.ForgotPasswordResetApi._update_existing_account")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.get_account_by_email_with_case_fallback")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.revoke_reset_password_token")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.get_reset_password_data")
|
||||
def test_reset_fetches_account_with_original_email(
|
||||
self,
|
||||
mock_get_reset_data,
|
||||
mock_revoke_token,
|
||||
mock_get_account,
|
||||
mock_update_account,
|
||||
database_app: Flask,
|
||||
@pytest.mark.parametrize(
|
||||
("service_error", "request_error"),
|
||||
[
|
||||
(account_errors.ForgotPasswordSendIPLimitedError(), EmailSendIpLimitError),
|
||||
(account_errors.ForgotPasswordSendRateLimitError(2), PasswordResetRateLimitExceededError),
|
||||
],
|
||||
)
|
||||
def test_send_maps_application_errors(
|
||||
app: Flask,
|
||||
service: FakeForgotPasswordService,
|
||||
service_error: Exception,
|
||||
request_error: type[Exception],
|
||||
) -> None:
|
||||
service.error = service_error
|
||||
with (
|
||||
app.test_request_context(
|
||||
"/forgot-password",
|
||||
method="POST",
|
||||
json={"email": "user@example.com"},
|
||||
),
|
||||
pytest.raises(request_error),
|
||||
):
|
||||
mock_get_reset_data.return_value = {"phase": "reset", "email": "User@Example.com"}
|
||||
account = Account(name="User", email="user@example.com")
|
||||
db.session.add(account)
|
||||
db.session.commit()
|
||||
mock_get_account.return_value = account
|
||||
forgot_password.ForgotPasswordSendEmailApi().post()
|
||||
|
||||
wraps_features = SystemFeatureModel(
|
||||
deployment_edition=DeploymentEdition.COMMUNITY,
|
||||
enable_email_password_login=True,
|
||||
)
|
||||
with (
|
||||
config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD),
|
||||
patch(
|
||||
"controllers.console.wraps.SystemFeatureService.is_email_password_login_enabled",
|
||||
return_value=wraps_features.enable_email_password_login,
|
||||
),
|
||||
):
|
||||
with database_app.test_request_context(
|
||||
"/forgot-password/resets",
|
||||
method="POST",
|
||||
json={
|
||||
"token": "token-123",
|
||||
"new_password": "ValidPass123!",
|
||||
"password_confirm": "ValidPass123!",
|
||||
},
|
||||
):
|
||||
response = ForgotPasswordResetApi().post()
|
||||
|
||||
assert response == {"result": "success"}
|
||||
mock_get_reset_data.assert_called_once_with("token-123")
|
||||
mock_revoke_token.assert_called_once_with("token-123")
|
||||
mock_update_account.assert_called_once()
|
||||
def test_verify_serializes_promoted_token(app: Flask, service: FakeForgotPasswordService) -> None:
|
||||
with app.test_request_context(
|
||||
"/forgot-password/validity",
|
||||
method="POST",
|
||||
json={"email": "USER@example.com", "code": "123456", "token": "verification-token"},
|
||||
):
|
||||
response = forgot_password.ForgotPasswordCheckApi().post()
|
||||
|
||||
assert response == {"is_valid": True, "email": "user@example.com", "token": "promoted-token"}
|
||||
assert service.verify_arguments == {
|
||||
"email": "USER@example.com",
|
||||
"code": "123456",
|
||||
"token": "verification-token",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("service_error", "request_error"),
|
||||
[
|
||||
(account_errors.ForgotPasswordVerificationLimitError(), EmailPasswordResetLimitError),
|
||||
(account_errors.InvalidForgotPasswordTokenError(), InvalidTokenError),
|
||||
(account_errors.InvalidForgotPasswordEmailError(), InvalidEmailError),
|
||||
(account_errors.InvalidForgotPasswordCodeError(), EmailCodeError),
|
||||
],
|
||||
)
|
||||
def test_verify_maps_application_errors(
|
||||
app: Flask,
|
||||
service: FakeForgotPasswordService,
|
||||
service_error: Exception,
|
||||
request_error: type[Exception],
|
||||
) -> None:
|
||||
service.error = service_error
|
||||
with (
|
||||
app.test_request_context(
|
||||
"/forgot-password/validity",
|
||||
method="POST",
|
||||
json={"email": "user@example.com", "code": "123456", "token": "token"},
|
||||
),
|
||||
pytest.raises(request_error),
|
||||
):
|
||||
forgot_password.ForgotPasswordCheckApi().post()
|
||||
|
||||
|
||||
def test_reset_delegates_and_serializes_result(app: Flask, service: FakeForgotPasswordService) -> None:
|
||||
with app.test_request_context(
|
||||
"/forgot-password/resets",
|
||||
method="POST",
|
||||
json={
|
||||
"token": "reset-token",
|
||||
"new_password": "ValidPass123!",
|
||||
"password_confirm": "ValidPass123!",
|
||||
},
|
||||
):
|
||||
response = forgot_password.ForgotPasswordResetApi().post()
|
||||
|
||||
assert response == {"result": "success"}
|
||||
assert service.reset_arguments == {
|
||||
"token": "reset-token",
|
||||
"new_password": "ValidPass123!",
|
||||
"password_confirm": "ValidPass123!",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("service_error", "request_error"),
|
||||
[
|
||||
(account_errors.ForgotPasswordMismatchError(), PasswordMismatchError),
|
||||
(account_errors.InvalidForgotPasswordTokenError(), InvalidTokenError),
|
||||
(account_errors.AccountNotFoundError(), AccountNotFound),
|
||||
],
|
||||
)
|
||||
def test_reset_maps_application_errors(
|
||||
app: Flask,
|
||||
service: FakeForgotPasswordService,
|
||||
service_error: Exception,
|
||||
request_error: type[Exception],
|
||||
) -> None:
|
||||
service.error = service_error
|
||||
with (
|
||||
app.test_request_context(
|
||||
"/forgot-password/resets",
|
||||
method="POST",
|
||||
json={
|
||||
"token": "reset-token",
|
||||
"new_password": "ValidPass123!",
|
||||
"password_confirm": "ValidPass123!",
|
||||
},
|
||||
),
|
||||
pytest.raises(request_error),
|
||||
):
|
||||
forgot_password.ForgotPasswordResetApi().post()
|
||||
|
||||
@@ -1,478 +0,0 @@
|
||||
"""Unit tests for password reset controller flows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy.orm import Session, scoped_session, sessionmaker
|
||||
|
||||
from controllers.console.auth.error import (
|
||||
EmailCodeError,
|
||||
EmailPasswordResetLimitError,
|
||||
InvalidEmailError,
|
||||
InvalidTokenError,
|
||||
PasswordMismatchError,
|
||||
)
|
||||
from controllers.console.auth.forgot_password import (
|
||||
ForgotPasswordCheckApi,
|
||||
ForgotPasswordResetApi,
|
||||
ForgotPasswordSendEmailApi,
|
||||
)
|
||||
from controllers.console.error import AccountNotFound, EmailSendIpLimitError
|
||||
from enums import DeploymentEdition
|
||||
from models.account import Account, Tenant, TenantAccountJoin
|
||||
from services.entities.feature_entities import SystemFeatureModel
|
||||
from tests.unit_tests.config_override import apply_config_overrides
|
||||
|
||||
SQLITE_MODELS = (Account, Tenant, TenantAccountJoin)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _bind_database_session(session: Session) -> Generator[scoped_session[Session]]:
|
||||
"""Bind the controller's session proxy to the SQLite test engine."""
|
||||
|
||||
database_session = scoped_session(sessionmaker(bind=session.get_bind(), expire_on_commit=False))
|
||||
try:
|
||||
with patch("controllers.console.auth.forgot_password.db.session", database_session):
|
||||
yield database_session
|
||||
finally:
|
||||
database_session.remove()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def enable_password_login_wrappers(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Keep endpoint decorators deterministic without requiring the configured app database."""
|
||||
|
||||
apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.CLOUD)
|
||||
monkeypatch.setattr(
|
||||
"controllers.console.wraps.SystemFeatureService.is_email_password_login_enabled",
|
||||
lambda: SystemFeatureModel(
|
||||
deployment_edition=DeploymentEdition.COMMUNITY,
|
||||
enable_email_password_login=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class TestForgotPasswordSendEmailApi:
|
||||
"""Test cases for sending password reset emails."""
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [SQLITE_MODELS], indirect=True)
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.is_email_send_ip_limit")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.send_reset_password_email")
|
||||
def test_send_reset_email_success(
|
||||
self,
|
||||
mock_send_email,
|
||||
mock_is_ip_limit,
|
||||
app: Flask,
|
||||
sqlite_session: Session,
|
||||
):
|
||||
# Arrange
|
||||
mock_is_ip_limit.return_value = False
|
||||
mock_send_email.return_value = "reset_token_123"
|
||||
|
||||
# Act
|
||||
with (
|
||||
_bind_database_session(sqlite_session),
|
||||
app.test_request_context(
|
||||
"/forgot-password", method="POST", json={"email": "test@example.com", "language": "en-US"}
|
||||
),
|
||||
):
|
||||
api = ForgotPasswordSendEmailApi()
|
||||
response = api.post()
|
||||
|
||||
# Assert
|
||||
assert response["result"] == "success"
|
||||
assert response["data"] == "reset_token_123"
|
||||
mock_send_email.assert_called_once()
|
||||
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.is_email_send_ip_limit")
|
||||
def test_send_reset_email_ip_rate_limited(self, mock_is_ip_limit, app: Flask):
|
||||
"""
|
||||
Test password reset email blocked by IP rate limit.
|
||||
|
||||
Verifies that:
|
||||
- EmailSendIpLimitError is raised when IP limit exceeded
|
||||
- No email is sent when rate limited
|
||||
"""
|
||||
# Arrange
|
||||
mock_is_ip_limit.return_value = True
|
||||
|
||||
# Act & Assert
|
||||
with app.test_request_context("/forgot-password", method="POST", json={"email": "test@example.com"}):
|
||||
api = ForgotPasswordSendEmailApi()
|
||||
with pytest.raises(EmailSendIpLimitError):
|
||||
api.post()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("language_input", "expected_language"),
|
||||
[
|
||||
("zh-Hans", "zh-Hans"),
|
||||
("en-US", "en-US"),
|
||||
("fr-FR", "en-US"), # Defaults to en-US for unsupported
|
||||
(None, "en-US"), # Defaults to en-US when not provided
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("sqlite_session", [SQLITE_MODELS], indirect=True)
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.is_email_send_ip_limit")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.send_reset_password_email")
|
||||
def test_send_reset_email_language_handling(
|
||||
self,
|
||||
mock_send_email,
|
||||
mock_is_ip_limit,
|
||||
language_input,
|
||||
expected_language,
|
||||
app: Flask,
|
||||
sqlite_session: Session,
|
||||
):
|
||||
"""
|
||||
Test password reset email with different language preferences.
|
||||
|
||||
Verifies that:
|
||||
- Language parameter is correctly processed
|
||||
- Unsupported languages default to en-US
|
||||
"""
|
||||
# Arrange
|
||||
mock_is_ip_limit.return_value = False
|
||||
mock_send_email.return_value = "token"
|
||||
|
||||
# Act
|
||||
with (
|
||||
_bind_database_session(sqlite_session),
|
||||
app.test_request_context(
|
||||
"/forgot-password", method="POST", json={"email": "test@example.com", "language": language_input}
|
||||
),
|
||||
):
|
||||
api = ForgotPasswordSendEmailApi()
|
||||
api.post()
|
||||
|
||||
# Assert
|
||||
call_args = mock_send_email.call_args
|
||||
assert call_args.kwargs["language"] == expected_language
|
||||
|
||||
|
||||
class TestForgotPasswordCheckApi:
|
||||
"""Test cases for verifying password reset codes."""
|
||||
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.is_forgot_password_error_rate_limit")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.get_reset_password_data")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.revoke_reset_password_token")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.generate_reset_password_token")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.reset_forgot_password_error_rate_limit")
|
||||
def test_verify_code_success(
|
||||
self,
|
||||
mock_reset_rate_limit: MagicMock,
|
||||
mock_generate_token: MagicMock,
|
||||
mock_revoke_token: MagicMock,
|
||||
mock_get_data: MagicMock,
|
||||
mock_is_rate_limit,
|
||||
app: Flask,
|
||||
):
|
||||
"""
|
||||
Test successful verification code validation.
|
||||
|
||||
Verifies that:
|
||||
- Valid code is accepted
|
||||
- Old token is revoked
|
||||
- New token is generated for reset phase
|
||||
- Rate limit is reset on success
|
||||
"""
|
||||
# Arrange
|
||||
mock_is_rate_limit.return_value = False
|
||||
mock_get_data.return_value = {"email": "test@example.com", "code": "123456"}
|
||||
mock_generate_token.return_value = (None, "new_token")
|
||||
|
||||
# Act
|
||||
with app.test_request_context(
|
||||
"/forgot-password/validity",
|
||||
method="POST",
|
||||
json={"email": "test@example.com", "code": "123456", "token": "old_token"},
|
||||
):
|
||||
api = ForgotPasswordCheckApi()
|
||||
response = api.post()
|
||||
|
||||
# Assert
|
||||
assert response["is_valid"] is True
|
||||
assert response["email"] == "test@example.com"
|
||||
assert response["token"] == "new_token"
|
||||
mock_revoke_token.assert_called_once_with("old_token")
|
||||
mock_generate_token.assert_called_once_with(
|
||||
"test@example.com", code="123456", additional_data={"phase": "reset"}
|
||||
)
|
||||
mock_reset_rate_limit.assert_called_once_with("test@example.com")
|
||||
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.is_forgot_password_error_rate_limit")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.get_reset_password_data")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.revoke_reset_password_token")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.generate_reset_password_token")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.reset_forgot_password_error_rate_limit")
|
||||
def test_verify_code_preserves_token_email_case(
|
||||
self,
|
||||
mock_reset_rate_limit: MagicMock,
|
||||
mock_generate_token: MagicMock,
|
||||
mock_revoke_token: MagicMock,
|
||||
mock_get_data: MagicMock,
|
||||
mock_is_rate_limit,
|
||||
app: Flask,
|
||||
):
|
||||
mock_is_rate_limit.return_value = False
|
||||
mock_get_data.return_value = {"email": "User@Example.com", "code": "999888"}
|
||||
mock_generate_token.return_value = (None, "fresh-token")
|
||||
|
||||
with app.test_request_context(
|
||||
"/forgot-password/validity",
|
||||
method="POST",
|
||||
json={"email": "user@example.com", "code": "999888", "token": "upper_token"},
|
||||
):
|
||||
response = ForgotPasswordCheckApi().post()
|
||||
|
||||
assert response == {"is_valid": True, "email": "user@example.com", "token": "fresh-token"}
|
||||
mock_generate_token.assert_called_once_with(
|
||||
"User@Example.com", code="999888", additional_data={"phase": "reset"}
|
||||
)
|
||||
mock_revoke_token.assert_called_once_with("upper_token")
|
||||
mock_reset_rate_limit.assert_called_once_with("user@example.com")
|
||||
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.is_forgot_password_error_rate_limit")
|
||||
def test_verify_code_rate_limited(self, mock_is_rate_limit, app: Flask):
|
||||
"""
|
||||
Test code verification blocked by rate limit.
|
||||
|
||||
Verifies that:
|
||||
- EmailPasswordResetLimitError is raised when limit exceeded
|
||||
- Prevents brute force attacks on verification codes
|
||||
"""
|
||||
# Arrange
|
||||
mock_is_rate_limit.return_value = True
|
||||
|
||||
# Act & Assert
|
||||
with app.test_request_context(
|
||||
"/forgot-password/validity",
|
||||
method="POST",
|
||||
json={"email": "test@example.com", "code": "123456", "token": "token"},
|
||||
):
|
||||
api = ForgotPasswordCheckApi()
|
||||
with pytest.raises(EmailPasswordResetLimitError):
|
||||
api.post()
|
||||
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.is_forgot_password_error_rate_limit")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.get_reset_password_data")
|
||||
def test_verify_code_invalid_token(self, mock_get_data, mock_is_rate_limit, app: Flask):
|
||||
"""
|
||||
Test code verification with invalid token.
|
||||
|
||||
Verifies that:
|
||||
- InvalidTokenError is raised for invalid/expired tokens
|
||||
"""
|
||||
# Arrange
|
||||
mock_is_rate_limit.return_value = False
|
||||
mock_get_data.return_value = None
|
||||
|
||||
# Act & Assert
|
||||
with app.test_request_context(
|
||||
"/forgot-password/validity",
|
||||
method="POST",
|
||||
json={"email": "test@example.com", "code": "123456", "token": "invalid_token"},
|
||||
):
|
||||
api = ForgotPasswordCheckApi()
|
||||
with pytest.raises(InvalidTokenError):
|
||||
api.post()
|
||||
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.is_forgot_password_error_rate_limit")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.get_reset_password_data")
|
||||
def test_verify_code_email_mismatch(self, mock_get_data, mock_is_rate_limit, app: Flask):
|
||||
"""
|
||||
Test code verification with mismatched email.
|
||||
|
||||
Verifies that:
|
||||
- InvalidEmailError is raised when email doesn't match token
|
||||
- Prevents token abuse
|
||||
"""
|
||||
# Arrange
|
||||
mock_is_rate_limit.return_value = False
|
||||
mock_get_data.return_value = {"email": "original@example.com", "code": "123456"}
|
||||
|
||||
# Act & Assert
|
||||
with app.test_request_context(
|
||||
"/forgot-password/validity",
|
||||
method="POST",
|
||||
json={"email": "different@example.com", "code": "123456", "token": "token"},
|
||||
):
|
||||
api = ForgotPasswordCheckApi()
|
||||
with pytest.raises(InvalidEmailError):
|
||||
api.post()
|
||||
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.is_forgot_password_error_rate_limit")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.get_reset_password_data")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.add_forgot_password_error_rate_limit")
|
||||
def test_verify_code_wrong_code(self, mock_add_rate_limit, mock_get_data, mock_is_rate_limit, app: Flask):
|
||||
"""
|
||||
Test code verification with incorrect code.
|
||||
|
||||
Verifies that:
|
||||
- EmailCodeError is raised for wrong code
|
||||
- Rate limit counter is incremented
|
||||
"""
|
||||
# Arrange
|
||||
mock_is_rate_limit.return_value = False
|
||||
mock_get_data.return_value = {"email": "test@example.com", "code": "123456"}
|
||||
|
||||
# Act & Assert
|
||||
with app.test_request_context(
|
||||
"/forgot-password/validity",
|
||||
method="POST",
|
||||
json={"email": "test@example.com", "code": "wrong_code", "token": "token"},
|
||||
):
|
||||
api = ForgotPasswordCheckApi()
|
||||
with pytest.raises(EmailCodeError):
|
||||
api.post()
|
||||
|
||||
mock_add_rate_limit.assert_called_once_with("test@example.com")
|
||||
|
||||
|
||||
class TestForgotPasswordResetApi:
|
||||
"""Test cases for resetting password with verified token."""
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [SQLITE_MODELS], indirect=True)
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.get_reset_password_data")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.revoke_reset_password_token")
|
||||
def test_reset_password_success(
|
||||
self,
|
||||
mock_revoke_token: MagicMock,
|
||||
mock_get_data: MagicMock,
|
||||
app: Flask,
|
||||
sqlite_session: Session,
|
||||
):
|
||||
"""
|
||||
Test successful password reset.
|
||||
|
||||
Verifies that:
|
||||
- Password is updated with new hashed value
|
||||
- Token is revoked after use
|
||||
- Success response is returned
|
||||
"""
|
||||
# Arrange
|
||||
mock_get_data.return_value = {"email": "test@example.com", "phase": "reset"}
|
||||
|
||||
# Act
|
||||
with _bind_database_session(sqlite_session) as database_session:
|
||||
account = Account(name="Test User", email="test@example.com")
|
||||
tenant = Tenant(name="Test Workspace")
|
||||
database_session.add_all([account, tenant])
|
||||
database_session.flush()
|
||||
database_session.add(TenantAccountJoin(tenant_id=tenant.id, account_id=account.id))
|
||||
database_session.commit()
|
||||
account_id = account.id
|
||||
|
||||
with app.test_request_context(
|
||||
"/forgot-password/resets",
|
||||
method="POST",
|
||||
json={
|
||||
"token": "valid_token",
|
||||
"new_password": "NewPass123!",
|
||||
"password_confirm": "NewPass123!",
|
||||
},
|
||||
):
|
||||
api = ForgotPasswordResetApi()
|
||||
response = api.post()
|
||||
|
||||
updated_account = database_session.get(Account, account_id)
|
||||
|
||||
# Assert
|
||||
assert response["result"] == "success"
|
||||
mock_revoke_token.assert_called_once_with("valid_token")
|
||||
assert updated_account is not None
|
||||
assert updated_account.password is not None
|
||||
assert updated_account.password_salt is not None
|
||||
|
||||
def test_reset_password_mismatch(self, app: Flask):
|
||||
"""
|
||||
Test password reset with mismatched passwords.
|
||||
|
||||
Verifies that:
|
||||
- PasswordMismatchError is raised when passwords don't match
|
||||
- No password update occurs
|
||||
"""
|
||||
# Act & Assert
|
||||
with app.test_request_context(
|
||||
"/forgot-password/resets",
|
||||
method="POST",
|
||||
json={"token": "token", "new_password": "NewPass123!", "password_confirm": "DifferentPass123!"},
|
||||
):
|
||||
api = ForgotPasswordResetApi()
|
||||
with pytest.raises(PasswordMismatchError):
|
||||
api.post()
|
||||
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.get_reset_password_data")
|
||||
def test_reset_password_invalid_token(self, mock_get_data, app: Flask):
|
||||
"""
|
||||
Test password reset with invalid token.
|
||||
|
||||
Verifies that:
|
||||
- InvalidTokenError is raised for invalid/expired tokens
|
||||
"""
|
||||
# Arrange
|
||||
mock_get_data.return_value = None
|
||||
|
||||
# Act & Assert
|
||||
with app.test_request_context(
|
||||
"/forgot-password/resets",
|
||||
method="POST",
|
||||
json={"token": "invalid_token", "new_password": "NewPass123!", "password_confirm": "NewPass123!"},
|
||||
):
|
||||
api = ForgotPasswordResetApi()
|
||||
with pytest.raises(InvalidTokenError):
|
||||
api.post()
|
||||
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.get_reset_password_data")
|
||||
def test_reset_password_wrong_phase(self, mock_get_data, app: Flask):
|
||||
"""
|
||||
Test password reset with token not in reset phase.
|
||||
|
||||
Verifies that:
|
||||
- InvalidTokenError is raised when token is not in reset phase
|
||||
- Prevents use of verification-phase tokens for reset
|
||||
"""
|
||||
# Arrange
|
||||
mock_get_data.return_value = {"email": "test@example.com", "phase": "verify"}
|
||||
|
||||
# Act & Assert
|
||||
with app.test_request_context(
|
||||
"/forgot-password/resets",
|
||||
method="POST",
|
||||
json={"token": "token", "new_password": "NewPass123!", "password_confirm": "NewPass123!"},
|
||||
):
|
||||
api = ForgotPasswordResetApi()
|
||||
with pytest.raises(InvalidTokenError):
|
||||
api.post()
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [SQLITE_MODELS], indirect=True)
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.get_reset_password_data")
|
||||
@patch("controllers.console.auth.forgot_password.AccountService.revoke_reset_password_token")
|
||||
def test_reset_password_account_not_found(
|
||||
self, mock_revoke_token, mock_get_data, app: Flask, sqlite_session: Session
|
||||
):
|
||||
"""
|
||||
Test password reset for non-existent account.
|
||||
|
||||
Verifies that:
|
||||
- AccountNotFound is raised when account doesn't exist
|
||||
"""
|
||||
# Arrange
|
||||
mock_get_data.return_value = {"email": "nonexistent@example.com", "phase": "reset"}
|
||||
|
||||
# Act & Assert
|
||||
with (
|
||||
_bind_database_session(sqlite_session),
|
||||
app.test_request_context(
|
||||
"/forgot-password/resets",
|
||||
method="POST",
|
||||
json={"token": "token", "new_password": "NewPass123!", "password_confirm": "NewPass123!"},
|
||||
),
|
||||
):
|
||||
api = ForgotPasswordResetApi()
|
||||
with pytest.raises(AccountNotFound):
|
||||
api.post()
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -23,7 +24,7 @@ from repositories.account_integration_repository import SQLAlchemyAccountIntegra
|
||||
from repositories.account_repository import SQLAlchemyAccountRepository
|
||||
from repositories.app_site_command_repository import AppSiteCommandRepository
|
||||
from repositories.workflow_run_archive_repository import WorkflowRunArchiveBundleQueryRepository
|
||||
from services import recommended_app_catalog_gateway
|
||||
from services import account_forgot_password_service, recommended_app_catalog_gateway
|
||||
from services.account_activation_adapters import (
|
||||
BillingAccountActivationEligibility,
|
||||
BillingWorkspaceMembershipCache,
|
||||
@@ -38,6 +39,11 @@ from services.account_email_registration_adapters import (
|
||||
RedisEmailRegistrationSecurityGateway,
|
||||
TokenManagerEmailRegistrationTokenGateway,
|
||||
)
|
||||
from services.account_forgot_password_adapters import (
|
||||
RateLimiterForgotPasswordSendLimiter,
|
||||
RedisForgotPasswordSecurityGateway,
|
||||
RedisForgotPasswordTokenGateway,
|
||||
)
|
||||
from services.app_site_service import AppSiteService
|
||||
from services.auth.data_source_api_key_auth_service import DataSourceApiKeyAuthService
|
||||
from services.billing_portal_service import BillingPortalService
|
||||
@@ -371,6 +377,23 @@ def test_build_application_services_wires_account_profile_repository(
|
||||
assert isinstance(accounts, SQLAlchemyAccountRepository)
|
||||
assert accounts._session_factory is sqlite_session_factory
|
||||
assert services.accounts.password._accounts is accounts
|
||||
forgot_password = services.accounts.forgot_password
|
||||
assert forgot_password._accounts is accounts
|
||||
assert forgot_password._passwords is services.accounts.password._passwords
|
||||
tokens = cast(RedisForgotPasswordTokenGateway, forgot_password._tokens)
|
||||
send_limiter = cast(RateLimiterForgotPasswordSendLimiter, forgot_password._send_limits)
|
||||
security = cast(RedisForgotPasswordSecurityGateway, forgot_password._security)
|
||||
assert tokens._redis is send_limiter._rate_limiter._redis_client
|
||||
assert send_limiter._rate_limiter.prefix == account_forgot_password_service.FORGOT_PASSWORD_SEND_RATE_LIMIT_PREFIX
|
||||
assert (
|
||||
send_limiter._rate_limiter.max_attempts
|
||||
== account_forgot_password_service.FORGOT_PASSWORD_SEND_RATE_LIMIT_MAX_ATTEMPTS
|
||||
)
|
||||
assert (
|
||||
security._verification_failure_limit
|
||||
== account_forgot_password_service.FORGOT_PASSWORD_VERIFICATION_FAILURE_LIMIT
|
||||
)
|
||||
assert security._verification_key_prefix == account_forgot_password_service.FORGOT_PASSWORD_VERIFICATION_KEY_PREFIX
|
||||
assert services.accounts.initialization._accounts is accounts
|
||||
assert not services.accounts.initialization._invitation_required
|
||||
assert services.accounts.change_email._accounts is accounts
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.exc import MultipleResultsFound
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from models.account import Account, AccountIntegrate, AccountStatus, InvitationCode, InvitationCodeStatus
|
||||
@@ -111,6 +112,23 @@ def test_account_repository_finds_email_with_lowercase_fallback(
|
||||
assert account.email == "account@example.com"
|
||||
|
||||
|
||||
def test_account_repository_fails_closed_for_duplicate_email(
|
||||
sqlite_session: Session,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
sqlite_session.add_all(
|
||||
[
|
||||
Account(name="First", email="duplicate@example.com"),
|
||||
Account(name="Second", email="duplicate@example.com"),
|
||||
]
|
||||
)
|
||||
sqlite_session.commit()
|
||||
repository = SQLAlchemyAccountRepository(sqlite_session_factory)
|
||||
|
||||
with pytest.raises(MultipleResultsFound):
|
||||
repository.find_by_email("duplicate@example.com")
|
||||
|
||||
|
||||
def test_account_integration_repository_lists_integrations(
|
||||
sqlite_session: Session,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Tests for forgot-password infrastructure adapters."""
|
||||
|
||||
import json
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
|
||||
from extensions.ext_redis import RedisClientWrapper
|
||||
from services.account_forgot_password_adapters import RedisForgotPasswordTokenGateway
|
||||
from services.entities.account_entities import ForgotPasswordResetToken, ForgotPasswordVerificationToken
|
||||
|
||||
|
||||
class FakeRedis:
|
||||
def __init__(self) -> None:
|
||||
self.values: dict[str, object] = {}
|
||||
|
||||
def getdel(self, key: str) -> object | None:
|
||||
return self.values.pop(key, None)
|
||||
|
||||
def get(self, key: str) -> object | None:
|
||||
return self.values.get(key)
|
||||
|
||||
def setex(self, key: str, _seconds: int, value: object) -> bool:
|
||||
self.values[key] = value
|
||||
return True
|
||||
|
||||
def delete(self, key: str) -> int:
|
||||
return int(self.values.pop(key, None) is not None)
|
||||
|
||||
def eval(
|
||||
self,
|
||||
_script: str,
|
||||
_key_count: int,
|
||||
key: str,
|
||||
expected: str,
|
||||
*args: object,
|
||||
) -> int:
|
||||
if self.values.get(key) != expected:
|
||||
return 0
|
||||
replacement, _expiry_seconds = args
|
||||
self.values[key] = replacement
|
||||
return 1
|
||||
|
||||
|
||||
def _gateway(redis: FakeRedis | None = None) -> RedisForgotPasswordTokenGateway:
|
||||
return RedisForgotPasswordTokenGateway(
|
||||
redis=cast(RedisClientWrapper, redis or FakeRedis()),
|
||||
expiry_seconds=300,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
{"email": "User@Example.com", "code": "123456", "phase": "reset"},
|
||||
{"email": "user@example.com", "code": "123456", "phase": "unknown"},
|
||||
{"email": None, "code": "123456"},
|
||||
],
|
||||
)
|
||||
def test_token_gateway_read_verification_rejects_other_payloads_without_consuming(
|
||||
payload: dict[str, object],
|
||||
) -> None:
|
||||
redis = FakeRedis()
|
||||
redis.values["reset_password:token:token"] = json.dumps(payload)
|
||||
|
||||
assert _gateway(redis).read_verification("token") is None
|
||||
assert "reset_password:token:token" in redis.values
|
||||
|
||||
|
||||
def test_token_gateway_reads_verification_token_without_consuming() -> None:
|
||||
redis = FakeRedis()
|
||||
redis.values["reset_password:token:token"] = json.dumps({"email": "User@Example.com", "code": "123456"})
|
||||
gateway = _gateway(redis)
|
||||
expected = ForgotPasswordVerificationToken(email="User@Example.com", code="123456")
|
||||
|
||||
assert gateway.read_verification("token") == expected
|
||||
assert gateway.read_verification("token") == expected
|
||||
|
||||
|
||||
def test_token_gateway_allows_only_one_successful_reset_claim() -> None:
|
||||
redis = FakeRedis()
|
||||
redis.values["reset_password:token:token"] = json.dumps(
|
||||
{"email": "user@example.com", "code": "123456", "phase": "reset"}
|
||||
)
|
||||
gateway = _gateway(redis)
|
||||
|
||||
assert isinstance(gateway.claim_reset("token"), ForgotPasswordResetToken)
|
||||
assert gateway.claim_reset("token") is None
|
||||
|
||||
|
||||
def test_token_gateway_rejects_superseded_account_token() -> None:
|
||||
redis = FakeRedis()
|
||||
redis.values["reset_password:token:v1"] = json.dumps(
|
||||
{"account_id": "account-1", "email": "user@example.com", "code": "123456", "phase": "reset"}
|
||||
)
|
||||
redis.values["reset_password:account:account-1"] = "v2"
|
||||
|
||||
assert _gateway(redis).claim_reset("v1") is None
|
||||
|
||||
assert "reset_password:token:v1" not in redis.values
|
||||
|
||||
|
||||
def test_token_gateway_serializes_reset_phase() -> None:
|
||||
redis = FakeRedis()
|
||||
token = _gateway(redis).issue(
|
||||
ForgotPasswordResetToken(email="User@Example.com", code="123456", account_id="account-1")
|
||||
)
|
||||
|
||||
assert json.loads(cast(str, redis.values[f"reset_password:token:{token}"])) == {
|
||||
"token_type": "reset_password",
|
||||
"account_id": "account-1",
|
||||
"email": "User@Example.com",
|
||||
"code": "123456",
|
||||
"phase": "reset",
|
||||
}
|
||||
assert redis.values["reset_password:account:account-1"] == token
|
||||
|
||||
|
||||
def test_token_gateway_binds_verification_token_to_existing_account() -> None:
|
||||
redis = FakeRedis()
|
||||
token = _gateway(redis).issue(
|
||||
ForgotPasswordVerificationToken(
|
||||
email="User@Example.com",
|
||||
code="123456",
|
||||
account_id="account-1",
|
||||
)
|
||||
)
|
||||
|
||||
assert redis.values["reset_password:account:account-1"] == token
|
||||
|
||||
|
||||
def test_token_gateway_promotes_only_the_current_account_token() -> None:
|
||||
redis = FakeRedis()
|
||||
current_key = "reset_password:account:account-1"
|
||||
redis.values["reset_password:token:v1"] = json.dumps(
|
||||
{"account_id": "account-1", "email": "user@example.com", "code": "123456"}
|
||||
)
|
||||
redis.values[current_key] = "v1"
|
||||
gateway = _gateway(redis)
|
||||
|
||||
claimed = gateway.read_verification("v1")
|
||||
assert isinstance(claimed, ForgotPasswordVerificationToken)
|
||||
reset_token = gateway.promote("v1", claimed.promote())
|
||||
|
||||
assert reset_token is not None
|
||||
assert redis.values[current_key] == reset_token
|
||||
assert "reset_password:token:v1" not in redis.values
|
||||
assert json.loads(cast(str, redis.values[f"reset_password:token:{reset_token}"]))["phase"] == "reset"
|
||||
|
||||
|
||||
def test_token_gateway_promotion_does_not_revoke_concurrent_new_token() -> None:
|
||||
redis = FakeRedis()
|
||||
old_key = "reset_password:token:v1"
|
||||
current_key = "reset_password:account:account-1"
|
||||
redis.values[old_key] = json.dumps({"account_id": "account-1", "email": "user@example.com", "code": "123456"})
|
||||
redis.values[current_key] = "v1"
|
||||
gateway = _gateway(redis)
|
||||
|
||||
claimed = gateway.read_verification("v1")
|
||||
assert isinstance(claimed, ForgotPasswordVerificationToken)
|
||||
new_token = gateway.issue(
|
||||
ForgotPasswordVerificationToken(
|
||||
email="user@example.com",
|
||||
code="654321",
|
||||
account_id="account-1",
|
||||
)
|
||||
)
|
||||
|
||||
assert gateway.promote("v1", claimed.promote()) is None
|
||||
|
||||
assert redis.values[current_key] == new_token
|
||||
assert old_key not in redis.values
|
||||
assert f"reset_password:token:{new_token}" in redis.values
|
||||
|
||||
|
||||
def test_token_gateway_consumes_legacy_verification_token_when_promoting() -> None:
|
||||
redis = FakeRedis()
|
||||
old_key = "reset_password:token:legacy"
|
||||
redis.values[old_key] = json.dumps({"email": "user@example.com", "code": "123456"})
|
||||
gateway = _gateway(redis)
|
||||
|
||||
claimed = gateway.read_verification("legacy")
|
||||
assert isinstance(claimed, ForgotPasswordVerificationToken)
|
||||
reset_token = gateway.promote("legacy", claimed.promote())
|
||||
|
||||
assert reset_token is not None
|
||||
assert old_key not in redis.values
|
||||
assert f"reset_password:token:{reset_token}" in redis.values
|
||||
@@ -0,0 +1,510 @@
|
||||
"""Unit tests for the framework-neutral forgot-password application service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
|
||||
from services import account_errors
|
||||
from services.account_forgot_password_service import (
|
||||
AccountForgotPasswordService,
|
||||
ForgotPasswordCodeGenerator,
|
||||
ForgotPasswordNotificationGateway,
|
||||
ForgotPasswordRegistrationPolicy,
|
||||
ForgotPasswordSecurityGateway,
|
||||
ForgotPasswordSendLimiter,
|
||||
ForgotPasswordTokenGateway,
|
||||
)
|
||||
from services.account_ports import AccountPasswordHasher, AccountRepository
|
||||
from services.entities.account_entities import (
|
||||
AccountPasswordDigest,
|
||||
AccountSnapshot,
|
||||
ForgotPasswordResetToken,
|
||||
ForgotPasswordToken,
|
||||
ForgotPasswordVerificationToken,
|
||||
)
|
||||
|
||||
|
||||
def _account(*, email: str = "User@Example.com") -> AccountSnapshot:
|
||||
return AccountSnapshot(
|
||||
id="account-1",
|
||||
name="User",
|
||||
email=email,
|
||||
avatar=None,
|
||||
is_password_set=False,
|
||||
interface_language="en-US",
|
||||
interface_theme="light",
|
||||
timezone="UTC",
|
||||
last_login_at=None,
|
||||
last_login_ip=None,
|
||||
status="active",
|
||||
initialized_at=None,
|
||||
created_at=datetime(2026, 8, 1),
|
||||
)
|
||||
|
||||
|
||||
class FakeAccounts:
|
||||
def __init__(self, account: AccountSnapshot | None = None) -> None:
|
||||
self.account = account
|
||||
self.requested_ids: list[str] = []
|
||||
self.requested_emails: list[str] = []
|
||||
self.updated: tuple[str, AccountPasswordDigest] | None = None
|
||||
self.update_succeeds = True
|
||||
self.update_error: Exception | None = None
|
||||
|
||||
def get(self, account_id: str) -> AccountSnapshot | None:
|
||||
self.requested_ids.append(account_id)
|
||||
return self.account
|
||||
|
||||
def find_by_email(self, email: str) -> AccountSnapshot | None:
|
||||
self.requested_emails.append(email)
|
||||
return self.account
|
||||
|
||||
def update_password(self, account_id: str, password: AccountPasswordDigest) -> AccountSnapshot | None:
|
||||
self.updated = (account_id, password)
|
||||
if self.update_error is not None:
|
||||
raise self.update_error
|
||||
return self.account if self.update_succeeds else None
|
||||
|
||||
|
||||
class FakePasswords:
|
||||
def __init__(self) -> None:
|
||||
self.plaintext: str | None = None
|
||||
self.digest = AccountPasswordDigest(password_hash="hash", password_salt="salt")
|
||||
|
||||
def hash(self, password: str) -> AccountPasswordDigest:
|
||||
self.plaintext = password
|
||||
return self.digest
|
||||
|
||||
|
||||
class FakeTokens:
|
||||
def __init__(self) -> None:
|
||||
self.stored: dict[str, ForgotPasswordToken | None] = {}
|
||||
self.issued: list[ForgotPasswordToken] = []
|
||||
self.read_tokens: list[str] = []
|
||||
self.claimed_reset_tokens: list[str] = []
|
||||
self.promotions: list[tuple[str, ForgotPasswordResetToken]] = []
|
||||
self.promotion_succeeds = True
|
||||
|
||||
def read_verification(self, token: str) -> ForgotPasswordVerificationToken | None:
|
||||
self.read_tokens.append(token)
|
||||
token_data = self.stored.get(token)
|
||||
return token_data if isinstance(token_data, ForgotPasswordVerificationToken) else None
|
||||
|
||||
def claim_reset(self, token: str) -> ForgotPasswordResetToken | None:
|
||||
self.claimed_reset_tokens.append(token)
|
||||
token_data = self.stored.pop(token, None)
|
||||
return token_data if isinstance(token_data, ForgotPasswordResetToken) else None
|
||||
|
||||
def issue(self, token_data: ForgotPasswordToken) -> str:
|
||||
self.issued.append(token_data)
|
||||
return f"issued-{len(self.issued)}"
|
||||
|
||||
def promote(self, claimed_token: str, token_data: ForgotPasswordResetToken) -> str | None:
|
||||
self.promotions.append((claimed_token, token_data))
|
||||
self.stored.pop(claimed_token, None)
|
||||
if not self.promotion_succeeds:
|
||||
return None
|
||||
return self.issue(token_data)
|
||||
|
||||
|
||||
class FakeCodeGenerator:
|
||||
def generate(self) -> str:
|
||||
return "123456"
|
||||
|
||||
|
||||
class FakeNotifications:
|
||||
def __init__(self) -> None:
|
||||
self.sent: list[dict[str, object]] = []
|
||||
|
||||
def send(
|
||||
self,
|
||||
*,
|
||||
email: str,
|
||||
code: str,
|
||||
language: str,
|
||||
account_exists: bool,
|
||||
registration_allowed: bool,
|
||||
) -> None:
|
||||
self.sent.append(
|
||||
{
|
||||
"email": email,
|
||||
"code": code,
|
||||
"language": language,
|
||||
"account_exists": account_exists,
|
||||
"registration_allowed": registration_allowed,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class FakeSendLimiter:
|
||||
retry_after_minutes = 1
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.limited = False
|
||||
self.checked: list[str] = []
|
||||
self.recorded: list[str] = []
|
||||
|
||||
def is_limited(self, email: str) -> bool:
|
||||
self.checked.append(email)
|
||||
return self.limited
|
||||
|
||||
def record(self, email: str) -> None:
|
||||
self.recorded.append(email)
|
||||
|
||||
|
||||
class FakeSecurity:
|
||||
def __init__(self) -> None:
|
||||
self.ip_limited = False
|
||||
self.verification_limited = False
|
||||
self.checked_ips: list[str] = []
|
||||
self.failed_emails: list[str] = []
|
||||
self.reset_emails: list[str] = []
|
||||
|
||||
def is_ip_limited(self, ip_address: str) -> bool:
|
||||
self.checked_ips.append(ip_address)
|
||||
return self.ip_limited
|
||||
|
||||
def is_verification_limited(self, _email: str) -> bool:
|
||||
return self.verification_limited
|
||||
|
||||
def record_verification_failure(self, email: str) -> None:
|
||||
self.failed_emails.append(email)
|
||||
|
||||
def reset_verification_failures(self, email: str) -> None:
|
||||
self.reset_emails.append(email)
|
||||
|
||||
|
||||
class FakeRegistrationPolicy:
|
||||
def __init__(self, allowed: bool = True) -> None:
|
||||
self.allowed = allowed
|
||||
|
||||
def is_registration_allowed(self) -> bool:
|
||||
return self.allowed
|
||||
|
||||
|
||||
class Dependencies:
|
||||
def __init__(self, account: AccountSnapshot | None = None) -> None:
|
||||
self.accounts = FakeAccounts(account)
|
||||
self.passwords = FakePasswords()
|
||||
self.tokens = FakeTokens()
|
||||
self.codes = FakeCodeGenerator()
|
||||
self.notifications = FakeNotifications()
|
||||
self.send_limits = FakeSendLimiter()
|
||||
self.security = FakeSecurity()
|
||||
self.registration = FakeRegistrationPolicy()
|
||||
self.service = AccountForgotPasswordService(
|
||||
accounts=cast(AccountRepository, self.accounts),
|
||||
passwords=cast(AccountPasswordHasher, self.passwords),
|
||||
tokens=cast(ForgotPasswordTokenGateway, self.tokens),
|
||||
codes=cast(ForgotPasswordCodeGenerator, self.codes),
|
||||
notifications=cast(ForgotPasswordNotificationGateway, self.notifications),
|
||||
send_limits=cast(ForgotPasswordSendLimiter, self.send_limits),
|
||||
security=cast(ForgotPasswordSecurityGateway, self.security),
|
||||
registration=cast(ForgotPasswordRegistrationPolicy, self.registration),
|
||||
)
|
||||
|
||||
|
||||
def test_send_code_preserves_existing_account_email_case() -> None:
|
||||
dependencies = Dependencies(_account())
|
||||
|
||||
token = dependencies.service.send_code(
|
||||
email="USER@Example.com",
|
||||
language="zh-Hans",
|
||||
ip_address="127.0.0.1",
|
||||
)
|
||||
|
||||
assert token == "issued-1"
|
||||
assert dependencies.accounts.requested_emails == ["USER@Example.com"]
|
||||
assert dependencies.tokens.issued == [
|
||||
ForgotPasswordVerificationToken(email="User@Example.com", code="123456", account_id="account-1")
|
||||
]
|
||||
assert dependencies.notifications.sent == [
|
||||
{
|
||||
"email": "User@Example.com",
|
||||
"code": "123456",
|
||||
"language": "zh-Hans",
|
||||
"account_exists": True,
|
||||
"registration_allowed": True,
|
||||
}
|
||||
]
|
||||
assert dependencies.send_limits.recorded == ["User@Example.com"]
|
||||
|
||||
|
||||
def test_send_code_normalizes_unknown_account_email() -> None:
|
||||
dependencies = Dependencies()
|
||||
dependencies.registration.allowed = False
|
||||
|
||||
dependencies.service.send_code(
|
||||
email="MISSING@Example.com",
|
||||
language="en-US",
|
||||
ip_address="127.0.0.1",
|
||||
)
|
||||
|
||||
assert dependencies.tokens.issued == [ForgotPasswordVerificationToken(email="missing@example.com", code="123456")]
|
||||
assert dependencies.notifications.sent[0]["account_exists"] is False
|
||||
assert dependencies.notifications.sent[0]["registration_allowed"] is False
|
||||
|
||||
|
||||
def test_send_code_stops_at_ip_limit() -> None:
|
||||
dependencies = Dependencies(_account())
|
||||
dependencies.security.ip_limited = True
|
||||
|
||||
with pytest.raises(account_errors.ForgotPasswordSendIPLimitedError):
|
||||
dependencies.service.send_code(
|
||||
email="user@example.com",
|
||||
language="en-US",
|
||||
ip_address="127.0.0.1",
|
||||
)
|
||||
|
||||
assert dependencies.accounts.requested_emails == []
|
||||
assert dependencies.tokens.issued == []
|
||||
|
||||
|
||||
def test_send_code_stops_at_address_limit() -> None:
|
||||
dependencies = Dependencies(_account())
|
||||
dependencies.send_limits.limited = True
|
||||
|
||||
with pytest.raises(account_errors.ForgotPasswordSendRateLimitError) as exc_info:
|
||||
dependencies.service.send_code(
|
||||
email="user@example.com",
|
||||
language="en-US",
|
||||
ip_address="127.0.0.1",
|
||||
)
|
||||
|
||||
assert exc_info.value.retry_after_minutes == 1
|
||||
assert dependencies.tokens.issued == []
|
||||
|
||||
|
||||
def test_verify_code_promotes_token_and_resets_failure_limit() -> None:
|
||||
dependencies = Dependencies()
|
||||
dependencies.tokens.stored["verification-token"] = ForgotPasswordVerificationToken(
|
||||
email="User@Example.com",
|
||||
code="123456",
|
||||
account_id="account-1",
|
||||
)
|
||||
|
||||
result = dependencies.service.verify_code(
|
||||
email="USER@example.com",
|
||||
code="123456",
|
||||
token="verification-token",
|
||||
)
|
||||
|
||||
assert result.email == "user@example.com"
|
||||
assert result.token == "issued-1"
|
||||
assert dependencies.tokens.read_tokens == ["verification-token"]
|
||||
assert "verification-token" not in dependencies.tokens.stored
|
||||
assert dependencies.tokens.issued == [
|
||||
ForgotPasswordResetToken(email="User@Example.com", code="123456", account_id="account-1")
|
||||
]
|
||||
assert dependencies.tokens.promotions == [
|
||||
(
|
||||
"verification-token",
|
||||
ForgotPasswordResetToken(email="User@Example.com", code="123456", account_id="account-1"),
|
||||
)
|
||||
]
|
||||
assert dependencies.security.reset_emails == ["user@example.com"]
|
||||
|
||||
|
||||
def test_verify_code_rejects_promotion_after_token_is_superseded() -> None:
|
||||
dependencies = Dependencies()
|
||||
dependencies.tokens.stored["verification-token"] = ForgotPasswordVerificationToken(
|
||||
email="user@example.com",
|
||||
code="123456",
|
||||
account_id="account-1",
|
||||
)
|
||||
dependencies.tokens.promotion_succeeds = False
|
||||
|
||||
with pytest.raises(account_errors.InvalidForgotPasswordTokenError):
|
||||
dependencies.service.verify_code(
|
||||
email="user@example.com",
|
||||
code="123456",
|
||||
token="verification-token",
|
||||
)
|
||||
|
||||
assert "verification-token" not in dependencies.tokens.stored
|
||||
assert dependencies.security.reset_emails == []
|
||||
|
||||
|
||||
def test_verify_code_rejects_reset_phase_token() -> None:
|
||||
dependencies = Dependencies()
|
||||
dependencies.tokens.stored["reset-token"] = ForgotPasswordResetToken(
|
||||
email="user@example.com",
|
||||
code="123456",
|
||||
)
|
||||
|
||||
with pytest.raises(account_errors.InvalidForgotPasswordTokenError):
|
||||
dependencies.service.verify_code(
|
||||
email="user@example.com",
|
||||
code="123456",
|
||||
token="reset-token",
|
||||
)
|
||||
|
||||
assert "reset-token" in dependencies.tokens.stored
|
||||
|
||||
|
||||
def test_verify_code_rejects_mismatched_email() -> None:
|
||||
dependencies = Dependencies()
|
||||
dependencies.tokens.stored["token"] = ForgotPasswordVerificationToken(
|
||||
email="original@example.com",
|
||||
code="123456",
|
||||
)
|
||||
|
||||
with pytest.raises(account_errors.InvalidForgotPasswordEmailError):
|
||||
dependencies.service.verify_code(
|
||||
email="different@example.com",
|
||||
code="123456",
|
||||
token="token",
|
||||
)
|
||||
|
||||
assert "token" in dependencies.tokens.stored
|
||||
|
||||
|
||||
def test_verify_code_keeps_token_after_wrong_code_and_allows_retry() -> None:
|
||||
dependencies = Dependencies()
|
||||
dependencies.tokens.stored["token"] = ForgotPasswordVerificationToken(
|
||||
email="user@example.com",
|
||||
code="123456",
|
||||
)
|
||||
|
||||
with pytest.raises(account_errors.InvalidForgotPasswordCodeError):
|
||||
dependencies.service.verify_code(
|
||||
email="USER@example.com",
|
||||
code="wrong",
|
||||
token="token",
|
||||
)
|
||||
|
||||
assert dependencies.security.failed_emails == ["user@example.com"]
|
||||
assert "token" in dependencies.tokens.stored
|
||||
|
||||
result = dependencies.service.verify_code(
|
||||
email="USER@example.com",
|
||||
code="123456",
|
||||
token="token",
|
||||
)
|
||||
|
||||
assert result.token == "issued-1"
|
||||
assert "token" not in dependencies.tokens.stored
|
||||
|
||||
|
||||
def test_verify_code_stops_at_failure_limit() -> None:
|
||||
dependencies = Dependencies()
|
||||
dependencies.security.verification_limited = True
|
||||
|
||||
with pytest.raises(account_errors.ForgotPasswordVerificationLimitError):
|
||||
dependencies.service.verify_code(
|
||||
email="user@example.com",
|
||||
code="123456",
|
||||
token="token",
|
||||
)
|
||||
|
||||
|
||||
def test_reset_claims_token_and_updates_password() -> None:
|
||||
dependencies = Dependencies(_account(email="user@example.com"))
|
||||
dependencies.tokens.stored["reset-token"] = ForgotPasswordResetToken(
|
||||
email="User@Example.com",
|
||||
code="123456",
|
||||
account_id="account-1",
|
||||
)
|
||||
|
||||
dependencies.service.reset(
|
||||
token="reset-token",
|
||||
new_password="ValidPass123!",
|
||||
password_confirm="ValidPass123!",
|
||||
)
|
||||
|
||||
assert dependencies.tokens.claimed_reset_tokens == ["reset-token"]
|
||||
assert "reset-token" not in dependencies.tokens.stored
|
||||
assert dependencies.accounts.requested_ids == ["account-1"]
|
||||
assert dependencies.accounts.requested_emails == []
|
||||
assert dependencies.passwords.plaintext == "ValidPass123!"
|
||||
assert dependencies.accounts.updated == ("account-1", dependencies.passwords.digest)
|
||||
|
||||
|
||||
def test_reset_rejects_password_mismatch_before_reading_token() -> None:
|
||||
dependencies = Dependencies(_account())
|
||||
|
||||
with pytest.raises(account_errors.ForgotPasswordMismatchError):
|
||||
dependencies.service.reset(
|
||||
token="reset-token",
|
||||
new_password="ValidPass123!",
|
||||
password_confirm="OtherPass123!",
|
||||
)
|
||||
|
||||
assert dependencies.tokens.claimed_reset_tokens == []
|
||||
|
||||
|
||||
def test_reset_consumes_claimed_token_when_account_is_missing() -> None:
|
||||
dependencies = Dependencies()
|
||||
dependencies.tokens.stored["reset-token"] = ForgotPasswordResetToken(
|
||||
email="missing@example.com",
|
||||
code="123456",
|
||||
account_id="missing-account",
|
||||
)
|
||||
|
||||
with pytest.raises(account_errors.AccountNotFoundError):
|
||||
dependencies.service.reset(
|
||||
token="reset-token",
|
||||
new_password="ValidPass123!",
|
||||
password_confirm="ValidPass123!",
|
||||
)
|
||||
|
||||
assert "reset-token" not in dependencies.tokens.stored
|
||||
assert dependencies.accounts.updated is None
|
||||
|
||||
|
||||
def test_reset_consumes_claimed_token_when_password_commit_fails() -> None:
|
||||
dependencies = Dependencies(_account())
|
||||
dependencies.tokens.stored["reset-token"] = ForgotPasswordResetToken(
|
||||
email="user@example.com",
|
||||
code="123456",
|
||||
account_id="account-1",
|
||||
)
|
||||
dependencies.accounts.update_error = RuntimeError("database unavailable")
|
||||
|
||||
with pytest.raises(RuntimeError, match="database unavailable"):
|
||||
dependencies.service.reset(
|
||||
token="reset-token",
|
||||
new_password="ValidPass123!",
|
||||
password_confirm="ValidPass123!",
|
||||
)
|
||||
|
||||
assert dependencies.tokens.claimed_reset_tokens == ["reset-token"]
|
||||
assert "reset-token" not in dependencies.tokens.stored
|
||||
|
||||
|
||||
def test_reset_supports_legacy_token_without_account_id() -> None:
|
||||
dependencies = Dependencies(_account(email="user@example.com"))
|
||||
dependencies.tokens.stored["legacy-token"] = ForgotPasswordResetToken(
|
||||
email="User@Example.com",
|
||||
code="123456",
|
||||
)
|
||||
|
||||
dependencies.service.reset(
|
||||
token="legacy-token",
|
||||
new_password="ValidPass123!",
|
||||
password_confirm="ValidPass123!",
|
||||
)
|
||||
|
||||
assert dependencies.accounts.requested_ids == []
|
||||
assert dependencies.accounts.requested_emails == ["User@Example.com"]
|
||||
|
||||
|
||||
def test_reset_rejects_account_when_email_changed_after_token_issue() -> None:
|
||||
dependencies = Dependencies(_account(email="changed@example.com"))
|
||||
dependencies.tokens.stored["reset-token"] = ForgotPasswordResetToken(
|
||||
email="original@example.com",
|
||||
code="123456",
|
||||
account_id="account-1",
|
||||
)
|
||||
|
||||
with pytest.raises(account_errors.AccountNotFoundError):
|
||||
dependencies.service.reset(
|
||||
token="reset-token",
|
||||
new_password="ValidPass123!",
|
||||
password_confirm="ValidPass123!",
|
||||
)
|
||||
|
||||
assert dependencies.accounts.updated is None
|
||||
@@ -1,11 +1,11 @@
|
||||
import pytest
|
||||
|
||||
from services.account_errors import InvalidAccountPasswordError
|
||||
from services.account_password_hasher import LegacyAccountPasswordHasher
|
||||
from services.account_password_hasher import DefaultAccountPasswordHasher
|
||||
|
||||
|
||||
def test_hash_produces_a_digest_that_can_be_verified() -> None:
|
||||
passwords = LegacyAccountPasswordHasher()
|
||||
passwords = DefaultAccountPasswordHasher()
|
||||
|
||||
digest = passwords.hash("password123")
|
||||
|
||||
@@ -22,7 +22,7 @@ def test_hash_produces_a_digest_that_can_be_verified() -> None:
|
||||
|
||||
|
||||
def test_hash_maps_password_policy_validation() -> None:
|
||||
passwords = LegacyAccountPasswordHasher()
|
||||
passwords = DefaultAccountPasswordHasher()
|
||||
|
||||
with pytest.raises(InvalidAccountPasswordError, match="Password must contain letters and numbers"):
|
||||
passwords.hash("letters-only")
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Tests for shared account email security infrastructure."""
|
||||
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
from redis import RedisError
|
||||
|
||||
from extensions.ext_redis import RedisClientWrapper
|
||||
from services.account_security_gateway import RedisAccountEmailSecurityGateway
|
||||
|
||||
|
||||
class FailingRedis:
|
||||
def get(self, _key: str) -> None:
|
||||
raise RedisError("offline")
|
||||
|
||||
def delete(self, _key: str) -> None:
|
||||
raise RedisError("offline")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gateway() -> RedisAccountEmailSecurityGateway:
|
||||
return RedisAccountEmailSecurityGateway(
|
||||
redis=cast(RedisClientWrapper, FailingRedis()),
|
||||
email_send_ip_limit_per_minute=3,
|
||||
verification_failure_limit=5,
|
||||
verification_lockout_duration=3600,
|
||||
verification_key_prefix="forgot_password_error_rate_limit",
|
||||
)
|
||||
|
||||
|
||||
def test_redis_failures_remain_fail_open_but_are_logged(
|
||||
gateway: RedisAccountEmailSecurityGateway,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
with caplog.at_level(logging.WARNING, logger="services.account_security_gateway"):
|
||||
assert gateway.is_ip_limited("127.0.0.1") is False
|
||||
assert gateway.is_verification_limited("user@example.com") is False
|
||||
gateway.record_verification_failure("user@example.com")
|
||||
gateway.reset_verification_failures("user@example.com")
|
||||
|
||||
assert [record.getMessage() for record in caplog.records] == [
|
||||
"Redis unavailable while checking the account email IP limit",
|
||||
"Redis unavailable while checking the account email verification limit",
|
||||
"Redis unavailable while recording an account email verification failure",
|
||||
"Redis unavailable while resetting account email verification failures",
|
||||
]
|
||||
@@ -10,7 +10,6 @@ export type ForgotPasswordSendPayload = {
|
||||
}
|
||||
|
||||
export type ForgotPasswordEmailResponse = {
|
||||
code?: string | null
|
||||
data?: string | null
|
||||
result: string
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ export const zForgotPasswordSendPayload = z.object({
|
||||
* ForgotPasswordEmailResponse
|
||||
*/
|
||||
export const zForgotPasswordEmailResponse = z.object({
|
||||
code: z.string().nullish(),
|
||||
data: z.string().nullish(),
|
||||
result: z.string(),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user