refactor: promo and email bruteforce defence
This commit is contained in:
@@ -426,8 +426,17 @@ async def email_auth_verify_route(request: web.Request) -> web.Response:
|
||||
target_user_id=None,
|
||||
)
|
||||
if not verify_result.ok:
|
||||
await session.rollback()
|
||||
return _json_error(400, verify_result.error or "invalid_code", "Invalid code")
|
||||
await session.commit()
|
||||
status = 429 if verify_result.error == "rate_limited" else 400
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": False,
|
||||
"error": verify_result.error or "invalid_code",
|
||||
"retry_after": verify_result.retry_after,
|
||||
"message": "Invalid code",
|
||||
},
|
||||
status=status,
|
||||
)
|
||||
|
||||
db_user = await user_dal.get_user_by_email(session, email)
|
||||
created_user = False
|
||||
@@ -526,8 +535,17 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
|
||||
target_user_id=user_id,
|
||||
)
|
||||
if not verify_result.ok:
|
||||
await session.rollback()
|
||||
return _json_error(400, verify_result.error or "invalid_code", "Invalid code")
|
||||
await session.commit()
|
||||
status = 429 if verify_result.error == "rate_limited" else 400
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": False,
|
||||
"error": verify_result.error or "invalid_code",
|
||||
"retry_after": verify_result.retry_after,
|
||||
"message": "Invalid code",
|
||||
},
|
||||
status=status,
|
||||
)
|
||||
|
||||
current_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not current_user or current_user.is_banned:
|
||||
@@ -645,7 +663,7 @@ async def apply_promo_route(request: web.Request) -> web.Response:
|
||||
lang,
|
||||
)
|
||||
if not success:
|
||||
await session.rollback()
|
||||
await session.commit()
|
||||
return _json_error(400, "promo_apply_failed", str(result))
|
||||
await session.commit()
|
||||
end_date = result if isinstance(result, datetime) else None
|
||||
|
||||
@@ -4,7 +4,6 @@ from aiogram import Router, F, types, Bot
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from typing import Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from aiogram.utils.markdown import hcode
|
||||
|
||||
from config.settings import Settings
|
||||
from bot.states.user_states import UserPromoStates
|
||||
@@ -123,46 +122,41 @@ async def process_promo_code_input(message: types.Message, state: FSMContext,
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send suspicious promo notification: {e}")
|
||||
|
||||
response_to_user_text = _("promo_code_not_found",
|
||||
code=hcode(code_input.upper()))
|
||||
reply_markup = get_back_to_main_menu_markup(current_lang, i18n)
|
||||
success, result = await promo_code_service.apply_promo_code(
|
||||
session, user.id, code_input, current_lang)
|
||||
if success:
|
||||
await session.commit()
|
||||
logging.info(
|
||||
f"Promo code '{code_input}' successfully applied for user {user.id}."
|
||||
)
|
||||
|
||||
new_end_date = result if isinstance(result, datetime) else None
|
||||
active = await subscription_service.get_active_subscription_details(session, user.id)
|
||||
config_link_display = active.get("config_link") if active else None
|
||||
connect_button_url = active.get("connect_button_url") if active else None
|
||||
config_link_text = config_link_display or _("config_link_not_available")
|
||||
|
||||
response_to_user_text = _(
|
||||
"promo_code_applied_success_full",
|
||||
end_date=(new_end_date.strftime("%d.%m.%Y %H:%M:%S") if new_end_date else "N/A"),
|
||||
config_link=config_link_text,
|
||||
)
|
||||
reply_markup = get_connect_and_main_keyboard(
|
||||
current_lang,
|
||||
i18n,
|
||||
settings,
|
||||
config_link_display,
|
||||
connect_button_url=connect_button_url,
|
||||
)
|
||||
else:
|
||||
|
||||
success, result = await promo_code_service.apply_promo_code(
|
||||
session, user.id, code_input, current_lang)
|
||||
if success:
|
||||
await session.commit()
|
||||
logging.info(
|
||||
f"Promo code '{code_input}' successfully applied for user {user.id}."
|
||||
)
|
||||
|
||||
new_end_date = result if isinstance(result, datetime) else None
|
||||
active = await subscription_service.get_active_subscription_details(session, user.id)
|
||||
config_link_display = active.get("config_link") if active else None
|
||||
connect_button_url = active.get("connect_button_url") if active else None
|
||||
config_link_text = config_link_display or _("config_link_not_available")
|
||||
|
||||
response_to_user_text = _(
|
||||
"promo_code_applied_success_full",
|
||||
end_date=(new_end_date.strftime("%d.%m.%Y %H:%M:%S") if new_end_date else "N/A"),
|
||||
config_link=config_link_text,
|
||||
)
|
||||
reply_markup = get_connect_and_main_keyboard(
|
||||
current_lang,
|
||||
i18n,
|
||||
settings,
|
||||
config_link_display,
|
||||
connect_button_url=connect_button_url,
|
||||
)
|
||||
else:
|
||||
await session.rollback()
|
||||
logging.info(
|
||||
f"Promo code '{code_input}' application failed for user {user.id}. Reason: {result}"
|
||||
)
|
||||
response_to_user_text = result
|
||||
reply_markup = get_back_to_main_menu_markup(
|
||||
current_lang, i18n
|
||||
)
|
||||
await session.commit()
|
||||
logging.info(
|
||||
f"Promo code '{code_input}' application failed for user {user.id}. Reason: {result}"
|
||||
)
|
||||
response_to_user_text = result
|
||||
reply_markup = get_back_to_main_menu_markup(
|
||||
current_lang, i18n
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
response_to_user_text,
|
||||
|
||||
@@ -688,7 +688,7 @@ async def start_command_handler(message: types.Message,
|
||||
# Don't show main menu if promo was successfully applied
|
||||
return
|
||||
else:
|
||||
await session.rollback()
|
||||
await session.commit()
|
||||
logging.warning(f"Failed to auto-apply promo code '{promo_code_to_apply}' for user {user_id}: {result}")
|
||||
await message.answer(str(result), parse_mode="HTML")
|
||||
# Continue to show main menu if promo failed
|
||||
|
||||
@@ -16,6 +16,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config.settings import Settings
|
||||
from db.dal import security_dal
|
||||
from db.models import EmailVerificationCode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -41,6 +42,7 @@ class EmailCodeRequestResult:
|
||||
class EmailCodeVerifyResult:
|
||||
ok: bool
|
||||
error: Optional[str] = None
|
||||
retry_after: Optional[int] = None
|
||||
|
||||
|
||||
def normalize_email(value: str) -> str:
|
||||
@@ -52,6 +54,11 @@ def is_valid_email(value: str) -> bool:
|
||||
return bool(email and len(email) <= 254 and EMAIL_RE.match(email))
|
||||
|
||||
|
||||
def _email_throttle_identifier(email: str, purpose: str, target_user_id: Optional[int]) -> str:
|
||||
target_part = "none" if target_user_id is None else str(target_user_id)
|
||||
return f"{purpose}:{target_part}:{email}"
|
||||
|
||||
|
||||
class EmailAuthService:
|
||||
def __init__(self, settings: Settings):
|
||||
self.settings = settings
|
||||
@@ -106,6 +113,19 @@ class EmailAuthService:
|
||||
return EmailCodeRequestResult(ok=False, error="invalid_email")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
throttle = await security_dal.check_throttle(
|
||||
session,
|
||||
scope=security_dal.EMAIL_CODE_VERIFY_SCOPE,
|
||||
identifier=_email_throttle_identifier(normalized_email, purpose, target_user_id),
|
||||
now=now,
|
||||
)
|
||||
if throttle.locked:
|
||||
return EmailCodeRequestResult(
|
||||
ok=False,
|
||||
error="rate_limited",
|
||||
retry_after=throttle.retry_after,
|
||||
)
|
||||
|
||||
latest_code = await self._get_latest_code(
|
||||
session,
|
||||
email=normalized_email,
|
||||
@@ -154,9 +174,28 @@ class EmailAuthService:
|
||||
) -> EmailCodeVerifyResult:
|
||||
normalized_email = normalize_email(email)
|
||||
normalized_code = re.sub(r"\D", "", code or "")
|
||||
if not is_valid_email(normalized_email) or len(normalized_code) != 6:
|
||||
if not is_valid_email(normalized_email):
|
||||
return EmailCodeVerifyResult(ok=False, error="invalid_code")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
throttle_identifier = _email_throttle_identifier(
|
||||
normalized_email,
|
||||
purpose,
|
||||
target_user_id,
|
||||
)
|
||||
throttle = await security_dal.check_throttle(
|
||||
session,
|
||||
scope=security_dal.EMAIL_CODE_VERIFY_SCOPE,
|
||||
identifier=throttle_identifier,
|
||||
now=now,
|
||||
)
|
||||
if throttle.locked:
|
||||
return EmailCodeVerifyResult(
|
||||
ok=False,
|
||||
error="rate_limited",
|
||||
retry_after=throttle.retry_after,
|
||||
)
|
||||
|
||||
latest_code = await self._get_latest_code(
|
||||
session,
|
||||
email=normalized_email,
|
||||
@@ -166,7 +205,6 @@ class EmailAuthService:
|
||||
if not latest_code or latest_code.consumed_at is not None:
|
||||
return EmailCodeVerifyResult(ok=False, error="invalid_code")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
expires_at = latest_code.expires_at
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
@@ -177,13 +215,55 @@ class EmailAuthService:
|
||||
if int(latest_code.attempts or 0) >= max_attempts:
|
||||
return EmailCodeVerifyResult(ok=False, error="too_many_attempts")
|
||||
|
||||
if len(normalized_code) != 6:
|
||||
latest_code.attempts = int(latest_code.attempts or 0) + 1
|
||||
throttle_result = await security_dal.record_throttle_failure(
|
||||
session,
|
||||
scope=security_dal.EMAIL_CODE_VERIFY_SCOPE,
|
||||
identifier=throttle_identifier,
|
||||
max_failures=self.settings.BRUTE_FORCE_MAX_FAILURES,
|
||||
window_seconds=self.settings.BRUTE_FORCE_WINDOW_SECONDS,
|
||||
lock_seconds=self.settings.BRUTE_FORCE_LOCK_SECONDS,
|
||||
now=now,
|
||||
)
|
||||
await session.flush()
|
||||
if throttle_result.locked:
|
||||
return EmailCodeVerifyResult(
|
||||
ok=False,
|
||||
error="rate_limited",
|
||||
retry_after=throttle_result.retry_after,
|
||||
)
|
||||
if int(latest_code.attempts or 0) >= max_attempts:
|
||||
return EmailCodeVerifyResult(ok=False, error="too_many_attempts")
|
||||
return EmailCodeVerifyResult(ok=False, error="invalid_code")
|
||||
|
||||
expected_hash = self._hash_code(normalized_email, purpose, normalized_code)
|
||||
if not hmac.compare_digest(expected_hash, latest_code.code_hash):
|
||||
latest_code.attempts = int(latest_code.attempts or 0) + 1
|
||||
throttle_result = await security_dal.record_throttle_failure(
|
||||
session,
|
||||
scope=security_dal.EMAIL_CODE_VERIFY_SCOPE,
|
||||
identifier=throttle_identifier,
|
||||
max_failures=self.settings.BRUTE_FORCE_MAX_FAILURES,
|
||||
window_seconds=self.settings.BRUTE_FORCE_WINDOW_SECONDS,
|
||||
lock_seconds=self.settings.BRUTE_FORCE_LOCK_SECONDS,
|
||||
now=now,
|
||||
)
|
||||
await session.flush()
|
||||
if throttle_result.locked:
|
||||
return EmailCodeVerifyResult(
|
||||
ok=False,
|
||||
error="rate_limited",
|
||||
retry_after=throttle_result.retry_after,
|
||||
)
|
||||
return EmailCodeVerifyResult(ok=False, error="invalid_code")
|
||||
|
||||
latest_code.consumed_at = now
|
||||
await security_dal.clear_throttle_state(
|
||||
session,
|
||||
scope=security_dal.EMAIL_CODE_VERIFY_SCOPE,
|
||||
identifier=throttle_identifier,
|
||||
)
|
||||
await session.flush()
|
||||
return EmailCodeVerifyResult(ok=True)
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import logging
|
||||
from html import escape as html_escape
|
||||
from datetime import datetime
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import Optional, Tuple, Dict
|
||||
from aiogram import Bot
|
||||
|
||||
from config.settings import Settings
|
||||
from db.dal import security_dal
|
||||
|
||||
from db.dal import promo_code_dal, user_dal
|
||||
from db.models import PromoCode, User
|
||||
@@ -24,6 +26,9 @@ class PromoCodeService:
|
||||
self.bot = bot
|
||||
self.i18n = i18n
|
||||
|
||||
def _throttle_identifier(self, user_id: int) -> str:
|
||||
return f"user:{int(user_id)}"
|
||||
|
||||
async def apply_promo_code(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
@@ -32,19 +37,45 @@ class PromoCodeService:
|
||||
user_lang: str,
|
||||
) -> Tuple[bool, datetime | str]:
|
||||
_ = lambda k, **kw: self.i18n.gettext(user_lang, k, **kw)
|
||||
code_input_upper = code_input.strip().upper()
|
||||
code_input_upper = (code_input or "").strip().upper()[:100]
|
||||
code_display = html_escape(code_input_upper[:100], quote=False)
|
||||
throttle_identifier = self._throttle_identifier(user_id)
|
||||
|
||||
throttle = await security_dal.check_throttle(
|
||||
session,
|
||||
scope=security_dal.PROMO_CODE_APPLY_SCOPE,
|
||||
identifier=throttle_identifier,
|
||||
)
|
||||
if throttle.locked:
|
||||
return False, _(
|
||||
"promo_code_too_many_attempts",
|
||||
seconds=throttle.retry_after or max(1, int(self.settings.BRUTE_FORCE_LOCK_SECONDS)),
|
||||
)
|
||||
|
||||
promo_data = await promo_code_dal.get_active_promo_code_by_code_str(
|
||||
session, code_input_upper)
|
||||
|
||||
if not promo_data:
|
||||
return False, _("promo_code_not_found", code=code_input_upper)
|
||||
throttle_result = await security_dal.record_throttle_failure(
|
||||
session,
|
||||
scope=security_dal.PROMO_CODE_APPLY_SCOPE,
|
||||
identifier=throttle_identifier,
|
||||
max_failures=self.settings.BRUTE_FORCE_MAX_FAILURES,
|
||||
window_seconds=self.settings.BRUTE_FORCE_WINDOW_SECONDS,
|
||||
lock_seconds=self.settings.BRUTE_FORCE_LOCK_SECONDS,
|
||||
)
|
||||
if throttle_result.locked:
|
||||
return False, _(
|
||||
"promo_code_too_many_attempts",
|
||||
seconds=throttle_result.retry_after or max(1, int(self.settings.BRUTE_FORCE_LOCK_SECONDS)),
|
||||
)
|
||||
return False, _("promo_code_not_found", code=code_display)
|
||||
|
||||
existing_activation = await promo_code_dal.get_user_activation_for_promo(
|
||||
session, promo_data.promo_code_id, user_id)
|
||||
if existing_activation:
|
||||
return False, _("promo_code_already_used_by_user",
|
||||
code=code_input_upper)
|
||||
code=code_display)
|
||||
|
||||
bonus_days = promo_data.bonus_days
|
||||
|
||||
@@ -61,6 +92,11 @@ class PromoCodeService:
|
||||
session, promo_data.promo_code_id)
|
||||
|
||||
if activation_recorded and promo_incremented:
|
||||
await security_dal.clear_throttle_state(
|
||||
session,
|
||||
scope=security_dal.PROMO_CODE_APPLY_SCOPE,
|
||||
identifier=throttle_identifier,
|
||||
)
|
||||
# Send notification about promo activation
|
||||
try:
|
||||
notification_service = NotificationService(self.bot, self.settings, self.i18n)
|
||||
|
||||
Reference in New Issue
Block a user