refactor: promo and email bruteforce defence
This commit is contained in:
@@ -57,6 +57,9 @@ SMTP_USE_SSL=False # U
|
|||||||
EMAIL_CODE_TTL_SECONDS=600 # Email verification code lifetime
|
EMAIL_CODE_TTL_SECONDS=600 # Email verification code lifetime
|
||||||
EMAIL_CODE_RESEND_SECONDS=60 # Minimum delay between code sends
|
EMAIL_CODE_RESEND_SECONDS=60 # Minimum delay between code sends
|
||||||
EMAIL_CODE_MAX_ATTEMPTS=5 # Max attempts per code
|
EMAIL_CODE_MAX_ATTEMPTS=5 # Max attempts per code
|
||||||
|
BRUTE_FORCE_MAX_FAILURES=5 # Max failed code attempts in the throttle window
|
||||||
|
BRUTE_FORCE_WINDOW_SECONDS=900 # Rolling window used to count failures
|
||||||
|
BRUTE_FORCE_LOCK_SECONDS=1800 # Temporary lockout duration after too many failures
|
||||||
|
|
||||||
# Payment Method Toggles
|
# Payment Method Toggles
|
||||||
YOOKASSA_ENABLED=True # Turn on YOOKASSA
|
YOOKASSA_ENABLED=True # Turn on YOOKASSA
|
||||||
|
|||||||
@@ -83,6 +83,11 @@
|
|||||||
| `SMTP_USERNAME` / `SMTP_PASSWORD` | Логин и SMTP key/password из Brevo. Если не заданы вместе с `SMTP_FROM_EMAIL`, вход по email скрывается. | `user@smtp-brevo.com` |
|
| `SMTP_USERNAME` / `SMTP_PASSWORD` | Логин и SMTP key/password из Brevo. Если не заданы вместе с `SMTP_FROM_EMAIL`, вход по email скрывается. | `user@smtp-brevo.com` |
|
||||||
| `SMTP_FROM_EMAIL` / `SMTP_FROM_NAME` | Подтвержденный отправитель и отображаемое имя отправителя для писем с кодом. | `no-reply@example.com` |
|
| `SMTP_FROM_EMAIL` / `SMTP_FROM_NAME` | Подтвержденный отправитель и отображаемое имя отправителя для писем с кодом. | `no-reply@example.com` |
|
||||||
| `EMAIL_CODE_TTL_SECONDS` | Срок действия кода подтверждения email. | `600` |
|
| `EMAIL_CODE_TTL_SECONDS` | Срок действия кода подтверждения email. | `600` |
|
||||||
|
| `EMAIL_CODE_RESEND_SECONDS` | Минимальная пауза между отправками кода на один email. | `60` |
|
||||||
|
| `EMAIL_CODE_MAX_ATTEMPTS` | Максимум попыток на один конкретный код. | `5` |
|
||||||
|
| `BRUTE_FORCE_MAX_FAILURES` | Максимум неудачных попыток в окне защиты от перебора. | `5` |
|
||||||
|
| `BRUTE_FORCE_WINDOW_SECONDS` | Длительность окна, в котором считаются неудачные попытки. | `900` |
|
||||||
|
| `BRUTE_FORCE_LOCK_SECONDS` | Время временной блокировки после превышения лимита. | `1800` |
|
||||||
| `MY_DEVICES_SECTION_ENABLED` | Включить раздел «Мои устройства» в меню подписки (`true`/`false`). | `false` |
|
| `MY_DEVICES_SECTION_ENABLED` | Включить раздел «Мои устройства» в меню подписки (`true`/`false`). | `false` |
|
||||||
| `REQUIRED_CHANNEL_ID` | (Опционально) ID канала, на который пользователь должен подписаться перед использованием. Оставьте пустым, если проверка не нужна. | `-1001234567890` |
|
| `REQUIRED_CHANNEL_ID` | (Опционально) ID канала, на который пользователь должен подписаться перед использованием. Оставьте пустым, если проверка не нужна. | `-1001234567890` |
|
||||||
| `REQUIRED_CHANNEL_LINK` | (Опционально) Публичная ссылка или invite на канал для кнопки «Проверить подписку». | `https://t.me/your_channel` |
|
| `REQUIRED_CHANNEL_LINK` | (Опционально) Публичная ссылка или invite на канал для кнопки «Проверить подписку». | `https://t.me/your_channel` |
|
||||||
|
|||||||
@@ -426,8 +426,17 @@ async def email_auth_verify_route(request: web.Request) -> web.Response:
|
|||||||
target_user_id=None,
|
target_user_id=None,
|
||||||
)
|
)
|
||||||
if not verify_result.ok:
|
if not verify_result.ok:
|
||||||
await session.rollback()
|
await session.commit()
|
||||||
return _json_error(400, verify_result.error or "invalid_code", "Invalid code")
|
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)
|
db_user = await user_dal.get_user_by_email(session, email)
|
||||||
created_user = False
|
created_user = False
|
||||||
@@ -526,8 +535,17 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
|
|||||||
target_user_id=user_id,
|
target_user_id=user_id,
|
||||||
)
|
)
|
||||||
if not verify_result.ok:
|
if not verify_result.ok:
|
||||||
await session.rollback()
|
await session.commit()
|
||||||
return _json_error(400, verify_result.error or "invalid_code", "Invalid code")
|
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)
|
current_user = await user_dal.get_user_by_id(session, user_id)
|
||||||
if not current_user or current_user.is_banned:
|
if not current_user or current_user.is_banned:
|
||||||
@@ -645,7 +663,7 @@ async def apply_promo_route(request: web.Request) -> web.Response:
|
|||||||
lang,
|
lang,
|
||||||
)
|
)
|
||||||
if not success:
|
if not success:
|
||||||
await session.rollback()
|
await session.commit()
|
||||||
return _json_error(400, "promo_apply_failed", str(result))
|
return _json_error(400, "promo_apply_failed", str(result))
|
||||||
await session.commit()
|
await session.commit()
|
||||||
end_date = result if isinstance(result, datetime) else None
|
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 aiogram.fsm.context import FSMContext
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from aiogram.utils.markdown import hcode
|
|
||||||
|
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
from bot.states.user_states import UserPromoStates
|
from bot.states.user_states import UserPromoStates
|
||||||
@@ -123,11 +122,6 @@ async def process_promo_code_input(message: types.Message, state: FSMContext,
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Failed to send suspicious promo notification: {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)
|
|
||||||
else:
|
|
||||||
|
|
||||||
success, result = await promo_code_service.apply_promo_code(
|
success, result = await promo_code_service.apply_promo_code(
|
||||||
session, user.id, code_input, current_lang)
|
session, user.id, code_input, current_lang)
|
||||||
if success:
|
if success:
|
||||||
@@ -155,7 +149,7 @@ async def process_promo_code_input(message: types.Message, state: FSMContext,
|
|||||||
connect_button_url=connect_button_url,
|
connect_button_url=connect_button_url,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
await session.rollback()
|
await session.commit()
|
||||||
logging.info(
|
logging.info(
|
||||||
f"Promo code '{code_input}' application failed for user {user.id}. Reason: {result}"
|
f"Promo code '{code_input}' application failed for user {user.id}. Reason: {result}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -688,7 +688,7 @@ async def start_command_handler(message: types.Message,
|
|||||||
# Don't show main menu if promo was successfully applied
|
# Don't show main menu if promo was successfully applied
|
||||||
return
|
return
|
||||||
else:
|
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}")
|
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")
|
await message.answer(str(result), parse_mode="HTML")
|
||||||
# Continue to show main menu if promo failed
|
# Continue to show main menu if promo failed
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
|
from db.dal import security_dal
|
||||||
from db.models import EmailVerificationCode
|
from db.models import EmailVerificationCode
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -41,6 +42,7 @@ class EmailCodeRequestResult:
|
|||||||
class EmailCodeVerifyResult:
|
class EmailCodeVerifyResult:
|
||||||
ok: bool
|
ok: bool
|
||||||
error: Optional[str] = None
|
error: Optional[str] = None
|
||||||
|
retry_after: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
def normalize_email(value: str) -> str:
|
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))
|
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:
|
class EmailAuthService:
|
||||||
def __init__(self, settings: Settings):
|
def __init__(self, settings: Settings):
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
@@ -106,6 +113,19 @@ class EmailAuthService:
|
|||||||
return EmailCodeRequestResult(ok=False, error="invalid_email")
|
return EmailCodeRequestResult(ok=False, error="invalid_email")
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
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(
|
latest_code = await self._get_latest_code(
|
||||||
session,
|
session,
|
||||||
email=normalized_email,
|
email=normalized_email,
|
||||||
@@ -154,9 +174,28 @@ class EmailAuthService:
|
|||||||
) -> EmailCodeVerifyResult:
|
) -> EmailCodeVerifyResult:
|
||||||
normalized_email = normalize_email(email)
|
normalized_email = normalize_email(email)
|
||||||
normalized_code = re.sub(r"\D", "", code or "")
|
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")
|
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(
|
latest_code = await self._get_latest_code(
|
||||||
session,
|
session,
|
||||||
email=normalized_email,
|
email=normalized_email,
|
||||||
@@ -166,7 +205,6 @@ class EmailAuthService:
|
|||||||
if not latest_code or latest_code.consumed_at is not None:
|
if not latest_code or latest_code.consumed_at is not None:
|
||||||
return EmailCodeVerifyResult(ok=False, error="invalid_code")
|
return EmailCodeVerifyResult(ok=False, error="invalid_code")
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
expires_at = latest_code.expires_at
|
expires_at = latest_code.expires_at
|
||||||
if expires_at.tzinfo is None:
|
if expires_at.tzinfo is None:
|
||||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||||
@@ -177,13 +215,55 @@ class EmailAuthService:
|
|||||||
if int(latest_code.attempts or 0) >= max_attempts:
|
if int(latest_code.attempts or 0) >= max_attempts:
|
||||||
return EmailCodeVerifyResult(ok=False, error="too_many_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)
|
expected_hash = self._hash_code(normalized_email, purpose, normalized_code)
|
||||||
if not hmac.compare_digest(expected_hash, latest_code.code_hash):
|
if not hmac.compare_digest(expected_hash, latest_code.code_hash):
|
||||||
latest_code.attempts = int(latest_code.attempts or 0) + 1
|
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()
|
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")
|
return EmailCodeVerifyResult(ok=False, error="invalid_code")
|
||||||
|
|
||||||
latest_code.consumed_at = now
|
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()
|
await session.flush()
|
||||||
return EmailCodeVerifyResult(ok=True)
|
return EmailCodeVerifyResult(ok=True)
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import logging
|
import logging
|
||||||
|
from html import escape as html_escape
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from typing import Optional, Tuple, Dict
|
from typing import Optional, Tuple, Dict
|
||||||
from aiogram import Bot
|
from aiogram import Bot
|
||||||
|
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
|
from db.dal import security_dal
|
||||||
|
|
||||||
from db.dal import promo_code_dal, user_dal
|
from db.dal import promo_code_dal, user_dal
|
||||||
from db.models import PromoCode, User
|
from db.models import PromoCode, User
|
||||||
@@ -24,6 +26,9 @@ class PromoCodeService:
|
|||||||
self.bot = bot
|
self.bot = bot
|
||||||
self.i18n = i18n
|
self.i18n = i18n
|
||||||
|
|
||||||
|
def _throttle_identifier(self, user_id: int) -> str:
|
||||||
|
return f"user:{int(user_id)}"
|
||||||
|
|
||||||
async def apply_promo_code(
|
async def apply_promo_code(
|
||||||
self,
|
self,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
@@ -32,19 +37,45 @@ class PromoCodeService:
|
|||||||
user_lang: str,
|
user_lang: str,
|
||||||
) -> Tuple[bool, datetime | str]:
|
) -> Tuple[bool, datetime | str]:
|
||||||
_ = lambda k, **kw: self.i18n.gettext(user_lang, k, **kw)
|
_ = 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(
|
promo_data = await promo_code_dal.get_active_promo_code_by_code_str(
|
||||||
session, code_input_upper)
|
session, code_input_upper)
|
||||||
|
|
||||||
if not promo_data:
|
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(
|
existing_activation = await promo_code_dal.get_user_activation_for_promo(
|
||||||
session, promo_data.promo_code_id, user_id)
|
session, promo_data.promo_code_id, user_id)
|
||||||
if existing_activation:
|
if existing_activation:
|
||||||
return False, _("promo_code_already_used_by_user",
|
return False, _("promo_code_already_used_by_user",
|
||||||
code=code_input_upper)
|
code=code_display)
|
||||||
|
|
||||||
bonus_days = promo_data.bonus_days
|
bonus_days = promo_data.bonus_days
|
||||||
|
|
||||||
@@ -61,6 +92,11 @@ class PromoCodeService:
|
|||||||
session, promo_data.promo_code_id)
|
session, promo_data.promo_code_id)
|
||||||
|
|
||||||
if activation_recorded and promo_incremented:
|
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
|
# Send notification about promo activation
|
||||||
try:
|
try:
|
||||||
notification_service = NotificationService(self.bot, self.settings, self.i18n)
|
notification_service = NotificationService(self.bot, self.settings, self.i18n)
|
||||||
|
|||||||
@@ -228,6 +228,18 @@ class Settings(BaseSettings):
|
|||||||
EMAIL_CODE_TTL_SECONDS: int = Field(default=10 * 60)
|
EMAIL_CODE_TTL_SECONDS: int = Field(default=10 * 60)
|
||||||
EMAIL_CODE_RESEND_SECONDS: int = Field(default=60)
|
EMAIL_CODE_RESEND_SECONDS: int = Field(default=60)
|
||||||
EMAIL_CODE_MAX_ATTEMPTS: int = Field(default=5)
|
EMAIL_CODE_MAX_ATTEMPTS: int = Field(default=5)
|
||||||
|
BRUTE_FORCE_MAX_FAILURES: int = Field(
|
||||||
|
default=5,
|
||||||
|
description="Maximum failed code attempts allowed within the throttle window before a temporary lockout is applied.",
|
||||||
|
)
|
||||||
|
BRUTE_FORCE_WINDOW_SECONDS: int = Field(
|
||||||
|
default=15 * 60,
|
||||||
|
description="Rolling window used to count failed email and promo code attempts.",
|
||||||
|
)
|
||||||
|
BRUTE_FORCE_LOCK_SECONDS: int = Field(
|
||||||
|
default=30 * 60,
|
||||||
|
description="Temporary lockout duration applied after too many failed code attempts.",
|
||||||
|
)
|
||||||
|
|
||||||
LOGS_PAGE_SIZE: int = Field(default=10)
|
LOGS_PAGE_SIZE: int = Field(default=10)
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from . import panel_sync_dal
|
|||||||
from . import message_log_dal
|
from . import message_log_dal
|
||||||
from . import user_billing_dal
|
from . import user_billing_dal
|
||||||
from . import ad_dal
|
from . import ad_dal
|
||||||
|
from . import security_dal
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
"user_dal",
|
"user_dal",
|
||||||
@@ -16,6 +17,7 @@ __all__ = (
|
|||||||
"message_log_dal",
|
"message_log_dal",
|
||||||
"user_billing_dal",
|
"user_billing_dal",
|
||||||
"ad_dal",
|
"ad_dal",
|
||||||
|
"security_dal",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import case, delete, or_, select
|
||||||
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ..models import SecurityThrottle
|
||||||
|
|
||||||
|
EMAIL_CODE_VERIFY_SCOPE = "email_code_verify"
|
||||||
|
PROMO_CODE_APPLY_SCOPE = "promo_code_apply"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ThrottleDecision:
|
||||||
|
locked: bool
|
||||||
|
retry_after: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_now(value: Optional[datetime] = None) -> datetime:
|
||||||
|
if value is None:
|
||||||
|
value = datetime.now(timezone.utc)
|
||||||
|
if value.tzinfo is None:
|
||||||
|
return value.replace(tzinfo=timezone.utc)
|
||||||
|
return value.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _retry_after_seconds(locked_until: Optional[datetime], now: datetime) -> Optional[int]:
|
||||||
|
if not locked_until:
|
||||||
|
return None
|
||||||
|
locked_until = _utc_now(locked_until)
|
||||||
|
remaining = int((locked_until - now).total_seconds())
|
||||||
|
return max(1, remaining) if remaining > 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_throttle_state(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
scope: str,
|
||||||
|
identifier: str,
|
||||||
|
) -> Optional[SecurityThrottle]:
|
||||||
|
stmt = (
|
||||||
|
select(SecurityThrottle)
|
||||||
|
.where(
|
||||||
|
SecurityThrottle.scope == scope,
|
||||||
|
SecurityThrottle.identifier == identifier,
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def check_throttle(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
scope: str,
|
||||||
|
identifier: str,
|
||||||
|
now: Optional[datetime] = None,
|
||||||
|
) -> ThrottleDecision:
|
||||||
|
now = _utc_now(now)
|
||||||
|
row = await get_throttle_state(session, scope=scope, identifier=identifier)
|
||||||
|
if not row or not row.locked_until:
|
||||||
|
return ThrottleDecision(locked=False)
|
||||||
|
|
||||||
|
locked_until = _utc_now(row.locked_until)
|
||||||
|
if locked_until <= now:
|
||||||
|
return ThrottleDecision(locked=False)
|
||||||
|
|
||||||
|
return ThrottleDecision(
|
||||||
|
locked=True,
|
||||||
|
retry_after=_retry_after_seconds(locked_until, now),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def record_throttle_failure(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
scope: str,
|
||||||
|
identifier: str,
|
||||||
|
max_failures: int,
|
||||||
|
window_seconds: int,
|
||||||
|
lock_seconds: int,
|
||||||
|
now: Optional[datetime] = None,
|
||||||
|
) -> ThrottleDecision:
|
||||||
|
now = _utc_now(now)
|
||||||
|
max_failures = max(1, int(max_failures))
|
||||||
|
window_seconds = max(1, int(window_seconds))
|
||||||
|
lock_seconds = max(1, int(lock_seconds))
|
||||||
|
window_cutoff = now - timedelta(seconds=window_seconds)
|
||||||
|
lock_until = now + timedelta(seconds=lock_seconds)
|
||||||
|
|
||||||
|
failure_count_expr = case(
|
||||||
|
(
|
||||||
|
or_(
|
||||||
|
SecurityThrottle.window_started_at.is_(None),
|
||||||
|
SecurityThrottle.window_started_at <= window_cutoff,
|
||||||
|
),
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
else_=SecurityThrottle.failures + 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
pg_insert(SecurityThrottle)
|
||||||
|
.values(
|
||||||
|
scope=scope,
|
||||||
|
identifier=identifier,
|
||||||
|
failures=1,
|
||||||
|
window_started_at=now,
|
||||||
|
last_attempt_at=now,
|
||||||
|
locked_until=lock_until if max_failures <= 1 else None,
|
||||||
|
)
|
||||||
|
.on_conflict_do_update(
|
||||||
|
index_elements=[SecurityThrottle.scope, SecurityThrottle.identifier],
|
||||||
|
set_={
|
||||||
|
"failures": failure_count_expr,
|
||||||
|
"window_started_at": case(
|
||||||
|
(
|
||||||
|
or_(
|
||||||
|
SecurityThrottle.window_started_at.is_(None),
|
||||||
|
SecurityThrottle.window_started_at <= window_cutoff,
|
||||||
|
),
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
else_=SecurityThrottle.window_started_at,
|
||||||
|
),
|
||||||
|
"last_attempt_at": now,
|
||||||
|
"locked_until": case(
|
||||||
|
(failure_count_expr >= max_failures, lock_until),
|
||||||
|
else_=None,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.returning(SecurityThrottle.locked_until)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
locked_until = result.scalar_one_or_none()
|
||||||
|
locked_until = _utc_now(locked_until) if locked_until else None
|
||||||
|
if locked_until and locked_until > now:
|
||||||
|
return ThrottleDecision(
|
||||||
|
locked=True,
|
||||||
|
retry_after=_retry_after_seconds(locked_until, now),
|
||||||
|
)
|
||||||
|
return ThrottleDecision(locked=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def clear_throttle_state(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
scope: str,
|
||||||
|
identifier: str,
|
||||||
|
) -> None:
|
||||||
|
stmt = delete(SecurityThrottle).where(
|
||||||
|
SecurityThrottle.scope == scope,
|
||||||
|
SecurityThrottle.identifier == identifier,
|
||||||
|
)
|
||||||
|
await session.execute(stmt)
|
||||||
@@ -202,6 +202,43 @@ def _migration_0005_add_email_auth_fields(connection: Connection) -> None:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_0006_add_security_throttles(connection: Connection) -> None:
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS security_throttles (
|
||||||
|
throttle_id SERIAL PRIMARY KEY,
|
||||||
|
scope VARCHAR(64) NOT NULL,
|
||||||
|
identifier VARCHAR(512) NOT NULL,
|
||||||
|
failures INTEGER NOT NULL DEFAULT 0,
|
||||||
|
window_started_at TIMESTAMPTZ NULL,
|
||||||
|
locked_until TIMESTAMPTZ NULL,
|
||||||
|
last_attempt_at TIMESTAMPTZ NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NULL,
|
||||||
|
CONSTRAINT uq_security_throttles_scope_identifier UNIQUE (scope, identifier)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_security_throttles_scope
|
||||||
|
ON security_throttles (scope)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_security_throttles_locked_until
|
||||||
|
ON security_throttles (locked_until)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
MIGRATIONS: List[Migration] = [
|
MIGRATIONS: List[Migration] = [
|
||||||
Migration(
|
Migration(
|
||||||
id="0001_add_channel_subscription_fields",
|
id="0001_add_channel_subscription_fields",
|
||||||
@@ -228,6 +265,11 @@ MIGRATIONS: List[Migration] = [
|
|||||||
description="Add email login identities and verification codes",
|
description="Add email login identities and verification codes",
|
||||||
upgrade=_migration_0005_add_email_auth_fields,
|
upgrade=_migration_0005_add_email_auth_fields,
|
||||||
),
|
),
|
||||||
|
Migration(
|
||||||
|
id="0006_add_security_throttles",
|
||||||
|
description="Add generic lockout tracking for brute-force protection",
|
||||||
|
upgrade=_migration_0006_add_security_throttles,
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -110,6 +110,24 @@ class EmailVerificationCode(Base):
|
|||||||
target_user = relationship("User")
|
target_user = relationship("User")
|
||||||
|
|
||||||
|
|
||||||
|
class SecurityThrottle(Base):
|
||||||
|
__tablename__ = "security_throttles"
|
||||||
|
|
||||||
|
throttle_id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
scope = Column(String(64), nullable=False, index=True)
|
||||||
|
identifier = Column(String(512), nullable=False, index=True)
|
||||||
|
failures = Column(Integer, nullable=False, default=0)
|
||||||
|
window_started_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
locked_until = Column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
|
last_attempt_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("scope", "identifier", name="uq_security_throttles_scope_identifier"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Payment(Base):
|
class Payment(Base):
|
||||||
__tablename__ = "payments"
|
__tablename__ = "payments"
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,7 @@
|
|||||||
"traffic_used_with_period": "{traffic_used} ({traffic_period})",
|
"traffic_used_with_period": "{traffic_used} ({traffic_period})",
|
||||||
"promo_code_prompt": "Please enter your promo code:",
|
"promo_code_prompt": "Please enter your promo code:",
|
||||||
"promo_code_not_found": "Promo code <code>{code}</code> not found, expired, or already used the maximum number of times.",
|
"promo_code_not_found": "Promo code <code>{code}</code> not found, expired, or already used the maximum number of times.",
|
||||||
|
"promo_code_too_many_attempts": "Too many failed promo code attempts. Please try again in {seconds} sec.",
|
||||||
"promo_code_already_used_by_user": "You have already used promo code <code>{code}</code>.",
|
"promo_code_already_used_by_user": "You have already used promo code <code>{code}</code>.",
|
||||||
"promo_code_applied_success_full": "✅ Promo code applied successfully!\nSubscription active until {end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
|
"promo_code_applied_success_full": "✅ Promo code applied successfully!\nSubscription active until {end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
|
||||||
"error_applying_promo_bonus": "Failed to apply promo bonus. Please try again later or contact support.",
|
"error_applying_promo_bonus": "Failed to apply promo bonus. Please try again later or contact support.",
|
||||||
|
|||||||
@@ -87,6 +87,7 @@
|
|||||||
"traffic_used_with_period": "{traffic_used} ({traffic_period})",
|
"traffic_used_with_period": "{traffic_used} ({traffic_period})",
|
||||||
"promo_code_prompt": "Пожалуйста, введите ваш промокод:",
|
"promo_code_prompt": "Пожалуйста, введите ваш промокод:",
|
||||||
"promo_code_not_found": "Промокод <code>{code}</code> не найден, истек или уже использован максимальное количество раз.",
|
"promo_code_not_found": "Промокод <code>{code}</code> не найден, истек или уже использован максимальное количество раз.",
|
||||||
|
"promo_code_too_many_attempts": "Слишком много неудачных попыток ввода промокода. Повторите через {seconds} сек.",
|
||||||
"promo_code_already_used_by_user": "Вы уже активировали промокод <code>{code}</code>.",
|
"promo_code_already_used_by_user": "Вы уже активировали промокод <code>{code}</code>.",
|
||||||
"promo_code_applied_success_full": "✅ Промокод успешно применен!\nПодписка активна до {end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
|
"promo_code_applied_success_full": "✅ Промокод успешно применен!\nПодписка активна до {end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
|
||||||
"error_applying_promo_bonus": "Не удалось применить бонус по промокоду. Пожалуйста, попробуйте позже или свяжитесь с поддержкой.",
|
"error_applying_promo_bonus": "Не удалось применить бонус по промокоду. Пожалуйста, попробуйте позже или свяжитесь с поддержкой.",
|
||||||
|
|||||||
Reference in New Issue
Block a user