Skip to content

Commit

Permalink
fix: add rate limiting to prevent brute force on password reset (#13292)
Browse files Browse the repository at this point in the history
  • Loading branch information
zhangx1n authored Feb 8, 2025
1 parent c8dcde6 commit 982bca5
Show file tree
Hide file tree
Showing 4 changed files with 51 additions and 1 deletion.
5 changes: 5 additions & 0 deletions api/configs/feature/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,11 @@ class AuthConfig(BaseSettings):
default=86400,
)

FORGOT_PASSWORD_LOCKOUT_DURATION: PositiveInt = Field(
description="Time (in seconds) a user must wait before retrying password reset after exceeding the rate limit.",
default=86400,
)


class ModerationConfig(BaseSettings):
"""
Expand Down
6 changes: 6 additions & 0 deletions api/controllers/console/auth/error.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,9 @@ class EmailCodeAccountDeletionRateLimitExceededError(BaseHTTPException):
error_code = "email_code_account_deletion_rate_limit_exceeded"
description = "Too many account deletion emails have been sent. Please try again in 5 minutes."
code = 429


class EmailPasswordResetLimitError(BaseHTTPException):
error_code = "email_password_reset_limit"
description = "Too many failed password reset attempts. Please try again in 24 hours."
code = 429
14 changes: 13 additions & 1 deletion api/controllers/console/auth/forgot_password.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@

from constants.languages import languages
from controllers.console import api
from controllers.console.auth.error import EmailCodeError, InvalidEmailError, InvalidTokenError, PasswordMismatchError
from controllers.console.auth.error import (
EmailCodeError,
EmailPasswordResetLimitError,
InvalidEmailError,
InvalidTokenError,
PasswordMismatchError,
)
from controllers.console.error import AccountInFreezeError, AccountNotFound, EmailSendIpLimitError
from controllers.console.wraps import setup_required
from events.tenant_event import tenant_was_created
Expand Down Expand Up @@ -62,6 +68,10 @@ def post(self):

user_email = args["email"]

is_forgot_password_error_rate_limit = AccountService.is_forgot_password_error_rate_limit(args["email"])
if is_forgot_password_error_rate_limit:
raise EmailPasswordResetLimitError()

token_data = AccountService.get_reset_password_data(args["token"])
if token_data is None:
raise InvalidTokenError()
Expand All @@ -70,8 +80,10 @@ def post(self):
raise InvalidEmailError()

if args["code"] != token_data.get("code"):
AccountService.add_forgot_password_error_rate_limit(args["email"])
raise EmailCodeError()

AccountService.reset_forgot_password_error_rate_limit(args["email"])
return {"is_valid": True, "email": token_data.get("email")}


Expand Down
27 changes: 27 additions & 0 deletions api/services/account_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ class AccountService:
prefix="email_code_account_deletion_rate_limit", max_attempts=1, time_window=60 * 1
)
LOGIN_MAX_ERROR_LIMITS = 5
FORGOT_PASSWORD_MAX_ERROR_LIMITS = 5

@staticmethod
def _get_refresh_token_key(refresh_token: str) -> str:
Expand Down Expand Up @@ -503,6 +504,32 @@ def reset_login_error_rate_limit(email: str):
key = f"login_error_rate_limit:{email}"
redis_client.delete(key)

@staticmethod
def add_forgot_password_error_rate_limit(email: str) -> None:
key = f"forgot_password_error_rate_limit:{email}"
count = redis_client.get(key)
if count is None:
count = 0
count = int(count) + 1
redis_client.setex(key, dify_config.FORGOT_PASSWORD_LOCKOUT_DURATION, count)

@staticmethod
def is_forgot_password_error_rate_limit(email: str) -> bool:
key = f"forgot_password_error_rate_limit:{email}"
count = redis_client.get(key)
if count is None:
return False

count = int(count)
if count > AccountService.FORGOT_PASSWORD_MAX_ERROR_LIMITS:
return True
return False

@staticmethod
def reset_forgot_password_error_rate_limit(email: str):
key = f"forgot_password_error_rate_limit:{email}"
redis_client.delete(key)

@staticmethod
def is_email_send_ip_limit(ip_address: str):
minute_key = f"email_send_ip_limit_minute:{ip_address}"
Expand Down

0 comments on commit 982bca5

Please sign in to comment.