security: harden webhooks and session secrets
This commit is contained in:
@@ -5,6 +5,12 @@
|
|||||||
.gitattributes
|
.gitattributes
|
||||||
LICENSE
|
LICENSE
|
||||||
README.md
|
README.md
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
scratch_*.py
|
||||||
|
node_modules/
|
||||||
|
.git/
|
||||||
|
|
||||||
|
|
||||||
# CI
|
# CI
|
||||||
|
|||||||
+7
-3
@@ -3,8 +3,8 @@ BOT_TOKEN=your_bot_token_here #
|
|||||||
ADMIN_IDS=comma_separated_admin_ids # Your telegram ID
|
ADMIN_IDS=comma_separated_admin_ids # Your telegram ID
|
||||||
|
|
||||||
# PostgreSQL Database Connection Settings
|
# PostgreSQL Database Connection Settings
|
||||||
POSTGRES_USER=postgres # Database user name
|
POSTGRES_USER= # Required: database user name
|
||||||
POSTGRES_PASSWORD=postgres # Database password
|
POSTGRES_PASSWORD= # Required: database password
|
||||||
POSTGRES_HOST=remnawave-tg-shop-db # Database container name
|
POSTGRES_HOST=remnawave-tg-shop-db # Database container name
|
||||||
POSTGRES_PORT=5432 # Port
|
POSTGRES_PORT=5432 # Port
|
||||||
POSTGRES_DB=postgres # Database name
|
POSTGRES_DB=postgres # Database name
|
||||||
@@ -31,6 +31,7 @@ REQUIRED_CHANNEL_LINK=https://t.me/your_channel #
|
|||||||
|
|
||||||
# Webhook Base URL (used for Telegram and payment providers)
|
# Webhook Base URL (used for Telegram and payment providers)
|
||||||
WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld
|
WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld
|
||||||
|
TRUSTED_PROXIES=127.0.0.1,::1 # Reverse proxies trusted for X-Forwarded-For
|
||||||
|
|
||||||
# Subscription Mini App (same container, separate port)
|
# Subscription Mini App (same container, separate port)
|
||||||
WEBAPP_ENABLED=True # Run Mini App HTTP server
|
WEBAPP_ENABLED=True # Run Mini App HTTP server
|
||||||
@@ -39,7 +40,9 @@ WEBAPP_SERVER_PORT=8081 #
|
|||||||
WEBAPP_TITLE="Моя подписка" # Mini App title
|
WEBAPP_TITLE="Моя подписка" # Mini App title
|
||||||
WEBAPP_PRIMARY_COLOR="#00fe7a" # Main UI color
|
WEBAPP_PRIMARY_COLOR="#00fe7a" # Main UI color
|
||||||
WEBAPP_LOGO_URL= # Optional logo URL; shown in the header and login screen, leave empty to hide
|
WEBAPP_LOGO_URL= # Optional logo URL; shown in the header and login screen, leave empty to hide
|
||||||
WEBAPP_SESSION_TTL_SECONDS=2592000 # Web App session lifetime
|
WEBAPP_SESSION_SECRET= # Optional: HMAC secret for webapp sessions; generated if empty
|
||||||
|
WEBHOOK_SECRET_TOKEN= # Optional: Telegram webhook secret token; generated if empty
|
||||||
|
WEBAPP_SESSION_TTL_SECONDS=86400 # Web App session lifetime (24h)
|
||||||
WEBAPP_AUTH_MAX_AGE_SECONDS=86400 # Max Telegram initData age
|
WEBAPP_AUTH_MAX_AGE_SECONDS=86400 # Max Telegram initData age
|
||||||
WEBAPP_LOGIN_TOKEN_TTL_SECONDS=600 # External browser login link lifetime
|
WEBAPP_LOGIN_TOKEN_TTL_SECONDS=600 # External browser login link lifetime
|
||||||
|
|
||||||
@@ -92,6 +95,7 @@ FREEKASSA_API_KEY=your_api_key #
|
|||||||
FREEKASSA_SECOND_SECRET=your_second_secret # Secret word #2 (used to verify notifications)
|
FREEKASSA_SECOND_SECRET=your_second_secret # Secret word #2 (used to verify notifications)
|
||||||
FREEKASSA_PAYMENT_IP= # Public IP address reported to FreeKassa
|
FREEKASSA_PAYMENT_IP= # Public IP address reported to FreeKassa
|
||||||
FREEKASSA_PAYMENT_METHOD_ID=44 # Payment method ID, you can get it from https://merchant.freekassa.net/settings/currencies
|
FREEKASSA_PAYMENT_METHOD_ID=44 # Payment method ID, you can get it from https://merchant.freekassa.net/settings/currencies
|
||||||
|
FREEKASSA_TRUSTED_IPS=168.119.157.136,168.119.60.227,178.154.197.79,51.250.54.238 # FreeKassa webhook source IP allowlist
|
||||||
|
|
||||||
# CryptoBot Payment Gateway Configuration
|
# CryptoBot Payment Gateway Configuration
|
||||||
CRYPTOPAY_TOKEN= # API token for CryptoPay
|
CRYPTOPAY_TOKEN= # API token for CryptoPay
|
||||||
|
|||||||
+5
-1
@@ -3,10 +3,14 @@ bot_database.sqlite3
|
|||||||
|
|
||||||
# Игнорировать файлы окружения
|
# Игнорировать файлы окружения
|
||||||
.env
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
scratch_*.py
|
||||||
|
node_modules/
|
||||||
|
.git/
|
||||||
|
|
||||||
# Игнорировать кэш Python
|
# Игнорировать кэш Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
node_modules/
|
|
||||||
|
|
||||||
# Игнорировать настройки IDE (если используешь, например, PyCharm или VSCode)
|
# Игнорировать настройки IDE (если используешь, например, PyCharm или VSCode)
|
||||||
.idea/
|
.idea/
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import hmac
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
@@ -9,6 +10,13 @@ from sqlalchemy.orm import sessionmaker
|
|||||||
|
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
|
|
||||||
|
|
||||||
|
class SecureSimpleRequestHandler(SimpleRequestHandler):
|
||||||
|
def verify_secret(self, telegram_secret_token: str, bot: Bot) -> bool:
|
||||||
|
if not self.secret_token:
|
||||||
|
return False
|
||||||
|
return hmac.compare_digest(telegram_secret_token, self.secret_token)
|
||||||
|
|
||||||
TELEGRAM_WEB_APP_SDK_REFRESH_INTERVAL_SECONDS = 24 * 60 * 60
|
TELEGRAM_WEB_APP_SDK_REFRESH_INTERVAL_SECONDS = 24 * 60 * 60
|
||||||
|
|
||||||
|
|
||||||
@@ -55,8 +63,12 @@ async def build_and_start_web_app(
|
|||||||
telegram_uses_webhook_mode = bool(settings.WEBHOOK_BASE_URL)
|
telegram_uses_webhook_mode = bool(settings.WEBHOOK_BASE_URL)
|
||||||
|
|
||||||
if telegram_uses_webhook_mode:
|
if telegram_uses_webhook_mode:
|
||||||
telegram_webhook_path = f"/{settings.BOT_TOKEN}"
|
telegram_webhook_path = settings.telegram_webhook_path
|
||||||
app.router.add_post(telegram_webhook_path, SimpleRequestHandler(dispatcher=dp, bot=bot))
|
SecureSimpleRequestHandler(
|
||||||
|
dispatcher=dp,
|
||||||
|
bot=bot,
|
||||||
|
secret_token=settings.WEBHOOK_SECRET_TOKEN,
|
||||||
|
).register(app, path=telegram_webhook_path)
|
||||||
logging.info(
|
logging.info(
|
||||||
f"Telegram webhook route configured at: [POST] {telegram_webhook_path} (relative to base URL)"
|
f"Telegram webhook route configured at: [POST] {telegram_webhook_path} (relative to base URL)"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ def _urlsafe_b64decode(raw: str) -> bytes:
|
|||||||
|
|
||||||
def _session_secret(settings: Settings) -> bytes:
|
def _session_secret(settings: Settings) -> bytes:
|
||||||
return hmac.new(
|
return hmac.new(
|
||||||
settings.BOT_TOKEN.encode("utf-8"),
|
settings.WEBAPP_SESSION_SECRET.encode("utf-8"),
|
||||||
b"remnawave-tg-shop-webapp-session",
|
b"remnawave-tg-shop-webapp-session",
|
||||||
hashlib.sha256,
|
hashlib.sha256,
|
||||||
).digest()
|
).digest()
|
||||||
|
|||||||
@@ -25,12 +25,22 @@ from bot.services.notification_service import NotificationService
|
|||||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||||
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||||
from bot.utils.config_link import prepare_config_links
|
from bot.utils.config_link import prepare_config_links
|
||||||
|
from bot.utils.request_security import ip_in_allowlist, request_client_ip
|
||||||
|
|
||||||
payment_processing_lock = asyncio.Lock()
|
payment_processing_lock = asyncio.Lock()
|
||||||
|
|
||||||
YOOKASSA_EVENT_PAYMENT_SUCCEEDED = 'payment.succeeded'
|
YOOKASSA_EVENT_PAYMENT_SUCCEEDED = 'payment.succeeded'
|
||||||
YOOKASSA_EVENT_PAYMENT_CANCELED = 'payment.canceled'
|
YOOKASSA_EVENT_PAYMENT_CANCELED = 'payment.canceled'
|
||||||
YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE = 'payment.waiting_for_capture'
|
YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE = 'payment.waiting_for_capture'
|
||||||
|
YOOKASSA_WEBHOOK_ALLOWED_IPS = [
|
||||||
|
"185.71.76.0/27",
|
||||||
|
"185.71.77.0/27",
|
||||||
|
"77.75.153.0/25",
|
||||||
|
"77.75.156.11",
|
||||||
|
"77.75.156.35",
|
||||||
|
"77.75.154.128/25",
|
||||||
|
"2a02:5180::/32",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
async def process_successful_payment(session: AsyncSession, bot: Bot,
|
async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||||
@@ -484,6 +494,11 @@ async def yookassa_webhook_route(request: web.Request):
|
|||||||
status=500,
|
status=500,
|
||||||
text="Internal Server Error: Missing app context component")
|
text="Internal Server Error: Missing app context component")
|
||||||
|
|
||||||
|
client_ip = request_client_ip(request, trusted_proxies=settings.trusted_proxies)
|
||||||
|
if not ip_in_allowlist(client_ip, YOOKASSA_WEBHOOK_ALLOWED_IPS):
|
||||||
|
logging.warning("YooKassa webhook denied from unauthorized IP source.")
|
||||||
|
return web.Response(status=403)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
event_json = await request.json()
|
event_json = await request.json()
|
||||||
|
|
||||||
|
|||||||
+50
-50
@@ -40,6 +40,12 @@ from bot.handlers.admin.sync_admin import perform_sync
|
|||||||
from bot.utils.message_queue import init_queue_manager
|
from bot.utils.message_queue import init_queue_manager
|
||||||
|
|
||||||
|
|
||||||
|
def redact_token(value: str, token: Optional[str]) -> str:
|
||||||
|
if not value or not token:
|
||||||
|
return value
|
||||||
|
return value.replace(token, "***")
|
||||||
|
|
||||||
|
|
||||||
async def register_all_routers(dp: Dispatcher, settings: Settings):
|
async def register_all_routers(dp: Dispatcher, settings: Settings):
|
||||||
dp.include_router(build_root_router(settings))
|
dp.include_router(build_root_router(settings))
|
||||||
logging.info("All application routers registered.")
|
logging.info("All application routers registered.")
|
||||||
@@ -59,52 +65,48 @@ async def on_startup_configured(dispatcher: Dispatcher):
|
|||||||
telegram_webhook_url_to_set = settings.WEBHOOK_BASE_URL
|
telegram_webhook_url_to_set = settings.WEBHOOK_BASE_URL
|
||||||
if telegram_webhook_url_to_set:
|
if telegram_webhook_url_to_set:
|
||||||
full_telegram_webhook_url = (
|
full_telegram_webhook_url = (
|
||||||
f"{str(telegram_webhook_url_to_set).rstrip('/')}/{settings.BOT_TOKEN}"
|
f"{str(telegram_webhook_url_to_set).rstrip('/')}{settings.telegram_webhook_path}"
|
||||||
)
|
)
|
||||||
|
|
||||||
logging.info(
|
logging.info(
|
||||||
f"STARTUP: Attempting to set Telegram webhook to: {full_telegram_webhook_url if full_telegram_webhook_url != 'ERROR_URL_TOKEN_DETECTED' else 'HIDDEN DUE TO TOKEN'}"
|
"STARTUP: Attempting to set Telegram webhook to: %s",
|
||||||
|
redact_token(full_telegram_webhook_url, settings.BOT_TOKEN),
|
||||||
)
|
)
|
||||||
|
|
||||||
if full_telegram_webhook_url != "ERROR_URL_TOKEN_DETECTED":
|
try:
|
||||||
try:
|
current_webhook_info = await bot.get_webhook_info()
|
||||||
current_webhook_info = await bot.get_webhook_info()
|
logging.info(
|
||||||
logging.info(
|
f"STARTUP: Current Telegram webhook info BEFORE setting: {current_webhook_info.model_dump_json(exclude_none=True, indent=2)}"
|
||||||
f"STARTUP: Current Telegram webhook info BEFORE setting: {current_webhook_info.model_dump_json(exclude_none=True, indent=2)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
set_success = await bot.set_webhook(
|
|
||||||
url=full_telegram_webhook_url,
|
|
||||||
drop_pending_updates=True,
|
|
||||||
allowed_updates=dispatcher.resolve_used_update_types(),
|
|
||||||
)
|
|
||||||
if set_success:
|
|
||||||
logging.info(
|
|
||||||
f"STARTUP: bot.set_webhook to {full_telegram_webhook_url} returned SUCCESS (True)."
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logging.error(
|
|
||||||
f"STARTUP: bot.set_webhook to {full_telegram_webhook_url} returned FAILURE (False)."
|
|
||||||
)
|
|
||||||
|
|
||||||
new_webhook_info = await bot.get_webhook_info()
|
|
||||||
logging.info(
|
|
||||||
f"STARTUP: Telegram Webhook info AFTER setting: {new_webhook_info.model_dump_json(exclude_none=True, indent=2)}"
|
|
||||||
)
|
|
||||||
if not new_webhook_info.url:
|
|
||||||
logging.error(
|
|
||||||
"STARTUP: CRITICAL - Telegram Webhook URL is EMPTY after set attempt. Check bot token and URL validity."
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e_setwebhook:
|
|
||||||
logging.error(
|
|
||||||
f"STARTUP: EXCEPTION during set/get Telegram webhook: {e_setwebhook}",
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logging.error(
|
|
||||||
"STARTUP: Skipped setting Telegram webhook due to security or configuration error."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
set_success = await bot.set_webhook(
|
||||||
|
url=full_telegram_webhook_url,
|
||||||
|
secret_token=settings.WEBHOOK_SECRET_TOKEN,
|
||||||
|
drop_pending_updates=True,
|
||||||
|
allowed_updates=dispatcher.resolve_used_update_types(),
|
||||||
|
)
|
||||||
|
if set_success:
|
||||||
|
logging.info(
|
||||||
|
"STARTUP: bot.set_webhook to %s returned SUCCESS (True).",
|
||||||
|
redact_token(full_telegram_webhook_url, settings.BOT_TOKEN),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logging.error(
|
||||||
|
"STARTUP: bot.set_webhook to %s returned FAILURE (False).",
|
||||||
|
redact_token(full_telegram_webhook_url, settings.BOT_TOKEN),
|
||||||
|
)
|
||||||
|
|
||||||
|
new_webhook_info = await bot.get_webhook_info()
|
||||||
|
logging.info(
|
||||||
|
f"STARTUP: Telegram Webhook info AFTER setting: {new_webhook_info.model_dump_json(exclude_none=True, indent=2)}"
|
||||||
|
)
|
||||||
|
if not new_webhook_info.url:
|
||||||
|
logging.error(
|
||||||
|
"STARTUP: CRITICAL - Telegram Webhook URL is EMPTY after set attempt. Check bot token and URL validity."
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
logging.exception("STARTUP: EXCEPTION during set/get Telegram webhook.")
|
||||||
else:
|
else:
|
||||||
logging.error(
|
logging.error(
|
||||||
"STARTUP: WEBHOOK_BASE_URL not set in environment. Webhook mode is required. Exiting."
|
"STARTUP: WEBHOOK_BASE_URL not set in environment. Webhook mode is required. Exiting."
|
||||||
@@ -127,10 +129,8 @@ async def on_startup_configured(dispatcher: Dispatcher):
|
|||||||
logging.info(
|
logging.info(
|
||||||
"STARTUP: Mini app domain registered and default menu button restored."
|
"STARTUP: Mini app domain registered and default menu button restored."
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logging.error(
|
logging.exception("STARTUP: Failed to register mini app domain.")
|
||||||
f"STARTUP: Failed to register mini app domain: {e}", exc_info=True
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
bot_commands = [
|
bot_commands = [
|
||||||
@@ -144,16 +144,16 @@ async def on_startup_configured(dispatcher: Dispatcher):
|
|||||||
)
|
)
|
||||||
await bot.set_my_commands(bot_commands)
|
await bot.set_my_commands(bot_commands)
|
||||||
logging.info("STARTUP: bot command descriptions set.")
|
logging.info("STARTUP: bot command descriptions set.")
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logging.error(f"STARTUP: Failed to set bot commands: {e}", exc_info=True)
|
logging.exception("STARTUP: Failed to set bot commands.")
|
||||||
|
|
||||||
# Initialize message queue manager
|
# Initialize message queue manager
|
||||||
try:
|
try:
|
||||||
queue_manager = init_queue_manager(bot)
|
queue_manager = init_queue_manager(bot)
|
||||||
dispatcher["queue_manager"] = queue_manager
|
dispatcher["queue_manager"] = queue_manager
|
||||||
logging.info("STARTUP: Message queue manager initialized")
|
logging.info("STARTUP: Message queue manager initialized")
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logging.error(f"STARTUP: Failed to initialize message queue manager: {e}", exc_info=True)
|
logging.exception("STARTUP: Failed to initialize message queue manager.")
|
||||||
|
|
||||||
# Automatic sync on startup
|
# Automatic sync on startup
|
||||||
try:
|
try:
|
||||||
@@ -172,8 +172,8 @@ async def on_startup_configured(dispatcher: Dispatcher):
|
|||||||
else:
|
else:
|
||||||
logging.warning(f"STARTUP: Automatic sync completed with issues. Status: {sync_result.get('status', 'unknown')}")
|
logging.warning(f"STARTUP: Automatic sync completed with issues. Status: {sync_result.get('status', 'unknown')}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logging.error(f"STARTUP: Failed to run automatic sync: {e}", exc_info=True)
|
logging.exception("STARTUP: Failed to run automatic sync.")
|
||||||
|
|
||||||
logging.info("STARTUP: Bot on_startup_configured completed.")
|
logging.info("STARTUP: Bot on_startup_configured completed.")
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from email.message import EmailMessage
|
|||||||
from email.utils import formataddr
|
from email.utils import formataddr
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
@@ -145,6 +145,18 @@ class EmailAuthService:
|
|||||||
retry_after=resend_after - elapsed,
|
retry_after=resend_after - elapsed,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
await session.execute(
|
||||||
|
update(EmailVerificationCode)
|
||||||
|
.where(
|
||||||
|
EmailVerificationCode.email == normalized_email,
|
||||||
|
EmailVerificationCode.purpose == purpose,
|
||||||
|
EmailVerificationCode.target_user_id == target_user_id,
|
||||||
|
EmailVerificationCode.status == "active",
|
||||||
|
EmailVerificationCode.consumed_at.is_(None),
|
||||||
|
)
|
||||||
|
.values(status="superseded")
|
||||||
|
)
|
||||||
|
|
||||||
code = f"{secrets.randbelow(1_000_000):06d}"
|
code = f"{secrets.randbelow(1_000_000):06d}"
|
||||||
code_model = EmailVerificationCode(
|
code_model = EmailVerificationCode(
|
||||||
email=normalized_email,
|
email=normalized_email,
|
||||||
@@ -152,6 +164,7 @@ class EmailAuthService:
|
|||||||
purpose=purpose,
|
purpose=purpose,
|
||||||
target_user_id=target_user_id,
|
target_user_id=target_user_id,
|
||||||
expires_at=now + timedelta(seconds=max(60, int(self.settings.EMAIL_CODE_TTL_SECONDS))),
|
expires_at=now + timedelta(seconds=max(60, int(self.settings.EMAIL_CODE_TTL_SECONDS))),
|
||||||
|
status="active",
|
||||||
)
|
)
|
||||||
session.add(code_model)
|
session.add(code_model)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
@@ -281,6 +294,8 @@ class EmailAuthService:
|
|||||||
EmailVerificationCode.email == email,
|
EmailVerificationCode.email == email,
|
||||||
EmailVerificationCode.purpose == purpose,
|
EmailVerificationCode.purpose == purpose,
|
||||||
EmailVerificationCode.target_user_id == target_user_id,
|
EmailVerificationCode.target_user_id == target_user_id,
|
||||||
|
EmailVerificationCode.status == "active",
|
||||||
|
EmailVerificationCode.consumed_at.is_(None),
|
||||||
)
|
)
|
||||||
.order_by(EmailVerificationCode.created_at.desc())
|
.order_by(EmailVerificationCode.created_at.desc())
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import logging
|
|||||||
import time
|
import time
|
||||||
from decimal import Decimal, ROUND_HALF_UP
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
from typing import Optional, Dict, Any, Tuple
|
from typing import Optional, Dict, Any, Tuple
|
||||||
|
from urllib.parse import parse_qsl
|
||||||
|
|
||||||
from aiohttp import ClientSession, ClientTimeout, web
|
from aiohttp import ClientSession, ClientTimeout, web
|
||||||
from aiogram import Bot
|
from aiogram import Bot
|
||||||
@@ -21,6 +22,7 @@ from bot.services.notification_service import NotificationService
|
|||||||
from db.dal import payment_dal, user_dal
|
from db.dal import payment_dal, user_dal
|
||||||
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||||
from bot.utils.config_link import prepare_config_links
|
from bot.utils.config_link import prepare_config_links
|
||||||
|
from bot.utils.request_security import ip_in_allowlist, request_client_ip
|
||||||
|
|
||||||
|
|
||||||
class FreeKassaService:
|
class FreeKassaService:
|
||||||
@@ -169,69 +171,59 @@ class FreeKassaService:
|
|||||||
|
|
||||||
def _validate_signature(
|
def _validate_signature(
|
||||||
self,
|
self,
|
||||||
merchant_order_id: str,
|
raw_body: bytes,
|
||||||
amount: str,
|
|
||||||
provided_signature: str,
|
provided_signature: str,
|
||||||
payload: Optional[Dict[str, Any]] = None,
|
|
||||||
) -> bool:
|
) -> bool:
|
||||||
if not provided_signature:
|
if not provided_signature:
|
||||||
return False
|
return False
|
||||||
|
if not self.second_secret:
|
||||||
|
return False
|
||||||
|
|
||||||
if self.shop_id and self.second_secret:
|
expected_signature = hmac.new(
|
||||||
signature_source = f"{self.shop_id}:{amount}:{self.second_secret}:{merchant_order_id}"
|
self.second_secret.encode("utf-8"),
|
||||||
expected_signature = hashlib.md5(signature_source.encode("utf-8")).hexdigest()
|
raw_body,
|
||||||
if expected_signature.lower() == provided_signature.lower():
|
hashlib.sha256,
|
||||||
return True
|
).hexdigest()
|
||||||
|
return hmac.compare_digest(expected_signature, provided_signature)
|
||||||
if self.api_key and payload:
|
|
||||||
items = [
|
|
||||||
(key, value)
|
|
||||||
for key, value in payload.items()
|
|
||||||
if key not in {"signature", "SIGN"} and value is not None
|
|
||||||
]
|
|
||||||
items.sort(key=lambda pair: pair[0])
|
|
||||||
message = "|".join(str(value) for _, value in items)
|
|
||||||
alt_signature = hmac.new(self.api_key.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
||||||
if alt_signature.lower() == provided_signature.lower():
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def webhook_route(self, request: web.Request) -> web.Response:
|
async def webhook_route(self, request: web.Request) -> web.Response:
|
||||||
if not self.configured:
|
if not self.configured:
|
||||||
return web.Response(status=503, text="freekassa_disabled")
|
return web.Response(status=503, text="freekassa_disabled")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = await request.post()
|
client_ip = request_client_ip(request, trusted_proxies=self.settings.trusted_proxies)
|
||||||
|
if not ip_in_allowlist(client_ip, self.settings.freekassa_trusted_ips):
|
||||||
|
return web.Response(status=403)
|
||||||
|
|
||||||
|
raw_body = await request.read()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"FreeKassa webhook: failed to read POST data: {e}")
|
logging.error("FreeKassa webhook: failed to read request body: %s", e)
|
||||||
return web.Response(status=400, text="bad_request")
|
return web.Response(status=400, text="bad_request")
|
||||||
|
|
||||||
payload_dict: Dict[str, Any]
|
payload_dict: Dict[str, Any] = {}
|
||||||
if data:
|
if raw_body:
|
||||||
payload_dict = {str(k): v for k, v in data.items()}
|
|
||||||
else:
|
|
||||||
try:
|
try:
|
||||||
json_payload = await request.json()
|
if request.content_type.startswith("application/json"):
|
||||||
payload_dict = {str(k): v for k, v in json_payload.items()} if isinstance(json_payload, dict) else {}
|
decoded_json = json.loads(raw_body.decode("utf-8"))
|
||||||
data = json_payload
|
if isinstance(decoded_json, dict):
|
||||||
|
payload_dict = {str(k): v for k, v in decoded_json.items()}
|
||||||
|
else:
|
||||||
|
payload_dict = {
|
||||||
|
str(key): value
|
||||||
|
for key, value in parse_qsl(raw_body.decode("utf-8"), keep_blank_values=True)
|
||||||
|
}
|
||||||
except Exception:
|
except Exception:
|
||||||
payload_dict = {}
|
payload_dict = {}
|
||||||
data = {}
|
|
||||||
|
|
||||||
def _get(key: str, default: Optional[str] = None) -> Optional[str]:
|
def _get(key: str, default: Optional[str] = None) -> Optional[str]:
|
||||||
if isinstance(data, dict):
|
|
||||||
return data.get(key) or data.get(key.lower()) or default
|
|
||||||
return payload_dict.get(key) or payload_dict.get(key.lower()) or default
|
return payload_dict.get(key) or payload_dict.get(key.lower()) or default
|
||||||
|
|
||||||
merchant_id = _get("MERCHANT_ID")
|
merchant_id = _get("MERCHANT_ID")
|
||||||
if merchant_id != self.shop_id:
|
if merchant_id != self.shop_id:
|
||||||
logging.error(f"FreeKassa webhook: merchant mismatch (got {merchant_id})")
|
return web.Response(status=403)
|
||||||
return web.Response(status=403, text="merchant_mismatch")
|
|
||||||
|
|
||||||
signature = _get("SIGN") or _get("signature")
|
signature = _get("SIGN") or _get("signature")
|
||||||
if not signature:
|
if not signature:
|
||||||
logging.error("FreeKassa webhook: missing signature")
|
|
||||||
return web.Response(status=400, text="missing_signature")
|
return web.Response(status=400, text="missing_signature")
|
||||||
|
|
||||||
order_id_str = _get("MERCHANT_ORDER_ID") or _get("ORDER_ID") or _get("o")
|
order_id_str = _get("MERCHANT_ORDER_ID") or _get("ORDER_ID") or _get("o")
|
||||||
@@ -239,11 +231,9 @@ class FreeKassaService:
|
|||||||
provider_payment_id = _get("intid") or _get("payment_id") or _get("transaction_id")
|
provider_payment_id = _get("intid") or _get("payment_id") or _get("transaction_id")
|
||||||
|
|
||||||
if not order_id_str or not amount_str:
|
if not order_id_str or not amount_str:
|
||||||
logging.error("FreeKassa webhook: missing order_id or amount")
|
|
||||||
return web.Response(status=400, text="missing_data")
|
return web.Response(status=400, text="missing_data")
|
||||||
|
|
||||||
if not self._validate_signature(order_id_str, amount_str, signature, payload_dict):
|
if not self._validate_signature(raw_body, signature):
|
||||||
logging.error("FreeKassa webhook: invalid signature")
|
|
||||||
return web.Response(status=403, text="invalid_signature")
|
return web.Response(status=403, text="invalid_signature")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ class PanelWebhookService:
|
|||||||
self.i18n = i18n
|
self.i18n = i18n
|
||||||
self.async_session_factory = async_session_factory
|
self.async_session_factory = async_session_factory
|
||||||
self.panel_service = panel_service
|
self.panel_service = panel_service
|
||||||
|
if not self.settings.PANEL_WEBHOOK_SECRET:
|
||||||
|
logging.error(
|
||||||
|
"PANEL_WEBHOOK_SECRET is not configured. Panel webhooks will be rejected."
|
||||||
|
)
|
||||||
|
|
||||||
async def _send_message(
|
async def _send_message(
|
||||||
self,
|
self,
|
||||||
@@ -40,8 +44,8 @@ class PanelWebhookService:
|
|||||||
await self.bot.send_message(
|
await self.bot.send_message(
|
||||||
user_id, _(message_key, **kwargs), reply_markup=reply_markup
|
user_id, _(message_key, **kwargs), reply_markup=reply_markup
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logging.error(f"Failed to send notification to {user_id}: {e}")
|
logging.exception("Failed to send notification to %s", user_id)
|
||||||
|
|
||||||
async def handle_event(self, event_name: str, user_payload: dict):
|
async def handle_event(self, event_name: str, user_payload: dict):
|
||||||
telegram_id = user_payload.get("telegramId")
|
telegram_id = user_payload.get("telegramId")
|
||||||
@@ -139,16 +143,19 @@ class PanelWebhookService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def handle_webhook(self, raw_body: bytes, signature_header: Optional[str]) -> web.Response:
|
async def handle_webhook(self, raw_body: bytes, signature_header: Optional[str]) -> web.Response:
|
||||||
if self.settings.PANEL_WEBHOOK_SECRET:
|
if not self.settings.PANEL_WEBHOOK_SECRET:
|
||||||
if not signature_header:
|
return web.Response(status=401, text="unauthorized")
|
||||||
return web.Response(status=403, text="no_signature")
|
|
||||||
expected_sig = hmac.new(
|
if not signature_header:
|
||||||
self.settings.PANEL_WEBHOOK_SECRET.encode(),
|
return web.Response(status=401, text="unauthorized")
|
||||||
raw_body,
|
|
||||||
hashlib.sha256,
|
expected_sig = hmac.new(
|
||||||
).hexdigest()
|
self.settings.PANEL_WEBHOOK_SECRET.encode(),
|
||||||
if not hmac.compare_digest(expected_sig, signature_header):
|
raw_body,
|
||||||
return web.Response(status=403, text="invalid_signature")
|
hashlib.sha256,
|
||||||
|
).hexdigest()
|
||||||
|
if not hmac.compare_digest(expected_sig, signature_header):
|
||||||
|
return web.Response(status=401, text="unauthorized")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = json.loads(raw_body.decode())
|
payload = json.loads(raw_body.decode())
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import hmac
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from decimal import Decimal, ROUND_HALF_UP
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
@@ -141,7 +142,10 @@ class PlategaService:
|
|||||||
|
|
||||||
header_merchant = request.headers.get("X-MerchantId")
|
header_merchant = request.headers.get("X-MerchantId")
|
||||||
header_secret = request.headers.get("X-Secret")
|
header_secret = request.headers.get("X-Secret")
|
||||||
if header_merchant != self.merchant_id or header_secret != self.secret:
|
if not (
|
||||||
|
hmac.compare_digest(str(header_merchant or ""), str(self.merchant_id or ""))
|
||||||
|
and hmac.compare_digest(str(header_secret or ""), str(self.secret or ""))
|
||||||
|
):
|
||||||
logging.error("Platega webhook: invalid auth headers")
|
logging.error("Platega webhook: invalid auth headers")
|
||||||
return web.Response(status=403, text="forbidden")
|
return web.Response(status=403, text="forbidden")
|
||||||
|
|
||||||
|
|||||||
@@ -161,10 +161,11 @@ class YooKassaService:
|
|||||||
f"Amount: {amount} {currency}. Metadata: {metadata}. Receipt: {receipt_data_dict}"
|
f"Amount: {amount} {currency}. Metadata: {metadata}. Receipt: {receipt_data_dict}"
|
||||||
)
|
)
|
||||||
|
|
||||||
loop = asyncio.get_running_loop()
|
response = await asyncio.to_thread(
|
||||||
response = await loop.run_in_executor(
|
YooKassaPayment.create,
|
||||||
None, lambda: YooKassaPayment.create(payment_request,
|
payment_request,
|
||||||
idempotence_key))
|
idempotence_key,
|
||||||
|
)
|
||||||
|
|
||||||
logging.info(
|
logging.info(
|
||||||
f"YooKassa Payment.create response: ID={response.id}, Status={response.status}, Paid={response.paid}"
|
f"YooKassa Payment.create response: ID={response.id}, Status={response.status}, Paid={response.paid}"
|
||||||
@@ -216,9 +217,10 @@ class YooKassaService:
|
|||||||
f"Fetching payment info from YooKassa for ID: {payment_id_in_yookassa}"
|
f"Fetching payment info from YooKassa for ID: {payment_id_in_yookassa}"
|
||||||
)
|
)
|
||||||
|
|
||||||
loop = asyncio.get_running_loop()
|
payment_info_yk = await asyncio.to_thread(
|
||||||
payment_info_yk = await loop.run_in_executor(
|
YooKassaPayment.find_one,
|
||||||
None, lambda: YooKassaPayment.find_one(payment_id_in_yookassa))
|
payment_id_in_yookassa,
|
||||||
|
)
|
||||||
|
|
||||||
if payment_info_yk:
|
if payment_info_yk:
|
||||||
logging.info(
|
logging.info(
|
||||||
@@ -275,8 +277,7 @@ class YooKassaService:
|
|||||||
logging.error("YooKassa is not configured. Cannot cancel payment.")
|
logging.error("YooKassa is not configured. Cannot cancel payment.")
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
loop = asyncio.get_running_loop()
|
await asyncio.to_thread(YooKassaPayment.cancel, payment_id_in_yookassa)
|
||||||
await loop.run_in_executor(None, lambda: YooKassaPayment.cancel(payment_id_in_yookassa))
|
|
||||||
logging.info(f"Cancelled YooKassa payment {payment_id_in_yookassa}")
|
logging.info(f"Cancelled YooKassa payment {payment_id_in_yookassa}")
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
from typing import Optional, Sequence
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
|
||||||
|
def parse_ip_entries(raw_values: Optional[Sequence[str] | str]) -> list[ipaddress._BaseNetwork]:
|
||||||
|
if raw_values is None:
|
||||||
|
return []
|
||||||
|
if isinstance(raw_values, str):
|
||||||
|
values = [item.strip() for item in raw_values.split(",")]
|
||||||
|
else:
|
||||||
|
values = [str(item).strip() for item in raw_values]
|
||||||
|
|
||||||
|
parsed: list[ipaddress._BaseNetwork] = []
|
||||||
|
for value in values:
|
||||||
|
if not value:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
parsed.append(ipaddress.ip_network(value, strict=False))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ip(value: Optional[str]) -> Optional[ipaddress._BaseAddress]:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return ipaddress.ip_address(value.strip())
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _last_forwarded_ip(header_value: str) -> Optional[str]:
|
||||||
|
candidates = [item.strip() for item in header_value.split(",") if item.strip()]
|
||||||
|
if not candidates:
|
||||||
|
return None
|
||||||
|
candidate = candidates[-1]
|
||||||
|
return candidate if _parse_ip(candidate) is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def request_client_ip(
|
||||||
|
request: web.Request,
|
||||||
|
*,
|
||||||
|
trusted_proxies: Optional[Sequence[str] | str] = None,
|
||||||
|
) -> Optional[str]:
|
||||||
|
remote_ip = _parse_ip(request.remote or "")
|
||||||
|
forwarded_for = request.headers.get("X-Forwarded-For", "")
|
||||||
|
|
||||||
|
if remote_ip and forwarded_for:
|
||||||
|
trusted_networks = parse_ip_entries(trusted_proxies)
|
||||||
|
if any(remote_ip in network for network in trusted_networks):
|
||||||
|
forwarded_ip = _last_forwarded_ip(forwarded_for)
|
||||||
|
if forwarded_ip:
|
||||||
|
return forwarded_ip
|
||||||
|
|
||||||
|
if remote_ip:
|
||||||
|
return str(remote_ip)
|
||||||
|
|
||||||
|
forwarded_ip = _last_forwarded_ip(forwarded_for)
|
||||||
|
return forwarded_ip
|
||||||
|
|
||||||
|
|
||||||
|
def ip_in_allowlist(ip_value: Optional[str], allowed_entries: Optional[Sequence[str] | str]) -> bool:
|
||||||
|
parsed_ip = _parse_ip(ip_value)
|
||||||
|
if parsed_ip is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
allowed_networks = parse_ip_entries(allowed_entries)
|
||||||
|
return any(parsed_ip in network for network in allowed_networks)
|
||||||
+239
-5
@@ -1,8 +1,97 @@
|
|||||||
import logging
|
import logging
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
import os
|
||||||
from pydantic import Field, ValidationError, computed_field, field_validator
|
import secrets
|
||||||
from typing import Optional, List, Dict, Any
|
from typing import Optional, List, Dict, Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, ValidationError, computed_field, field_validator
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
def _split_csv(value: Optional[str]) -> List[str]:
|
||||||
|
if not value:
|
||||||
|
return []
|
||||||
|
return [item.strip() for item in value.split(",") if item.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
class DBSettings(BaseModel):
|
||||||
|
user: str
|
||||||
|
password: str
|
||||||
|
host: str
|
||||||
|
port: int
|
||||||
|
database: str
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentSettings(BaseModel):
|
||||||
|
yookassa_enabled: bool
|
||||||
|
yookassa_shop_id: Optional[str]
|
||||||
|
yookassa_secret_key: Optional[str]
|
||||||
|
yookassa_return_url: Optional[str]
|
||||||
|
yookassa_default_receipt_email: Optional[str]
|
||||||
|
yookassa_vat_code: int
|
||||||
|
yookassa_payment_mode: str
|
||||||
|
yookassa_payment_subject: str
|
||||||
|
yookassa_autopayments_enabled: bool
|
||||||
|
yookassa_autopayments_require_card_binding: bool
|
||||||
|
freekassa_enabled: bool
|
||||||
|
freekassa_merchant_id: Optional[str]
|
||||||
|
freekassa_second_secret: Optional[str]
|
||||||
|
freekassa_api_key: Optional[str]
|
||||||
|
freekassa_payment_ip: Optional[str]
|
||||||
|
freekassa_payment_method_id: Optional[int]
|
||||||
|
freekassa_trusted_ips: List[str]
|
||||||
|
platega_enabled: bool
|
||||||
|
platega_base_url: str
|
||||||
|
platega_merchant_id: Optional[str]
|
||||||
|
platega_secret: Optional[str]
|
||||||
|
platega_payment_method: int
|
||||||
|
platega_return_url: Optional[str]
|
||||||
|
platega_failed_url: Optional[str]
|
||||||
|
severpay_enabled: bool
|
||||||
|
severpay_mid: Optional[int]
|
||||||
|
severpay_token: Optional[str]
|
||||||
|
severpay_return_url: Optional[str]
|
||||||
|
severpay_base_url: str
|
||||||
|
severpay_lifetime_minutes: Optional[int]
|
||||||
|
cryptopay_enabled: bool
|
||||||
|
cryptopay_token: Optional[str]
|
||||||
|
cryptopay_network: str
|
||||||
|
cryptopay_currency_type: str
|
||||||
|
cryptopay_asset: str
|
||||||
|
|
||||||
|
|
||||||
|
class EmailSettings(BaseModel):
|
||||||
|
smtp_host: str
|
||||||
|
smtp_port: int
|
||||||
|
smtp_fallback_ports: Optional[str]
|
||||||
|
smtp_timeout_seconds: int
|
||||||
|
smtp_username: Optional[str]
|
||||||
|
smtp_password: Optional[str]
|
||||||
|
smtp_from_email: Optional[str]
|
||||||
|
smtp_from_name: Optional[str]
|
||||||
|
smtp_starttls: bool
|
||||||
|
smtp_use_ssl: bool
|
||||||
|
email_code_ttl_seconds: int
|
||||||
|
email_code_resend_seconds: int
|
||||||
|
email_code_max_attempts: int
|
||||||
|
brute_force_max_failures: int
|
||||||
|
brute_force_window_seconds: int
|
||||||
|
brute_force_lock_seconds: int
|
||||||
|
|
||||||
|
|
||||||
|
class WebAppSettings(BaseModel):
|
||||||
|
title: str
|
||||||
|
primary_color: str
|
||||||
|
logo_url: Optional[str]
|
||||||
|
session_ttl_seconds: int
|
||||||
|
session_secret: str
|
||||||
|
webhook_secret_token: str
|
||||||
|
auth_max_age_seconds: int
|
||||||
|
login_token_ttl_seconds: int
|
||||||
|
server_host: str
|
||||||
|
server_port: int
|
||||||
|
enabled: bool
|
||||||
|
trusted_proxies: List[str]
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
BOT_TOKEN: str
|
BOT_TOKEN: str
|
||||||
@@ -11,8 +100,8 @@ class Settings(BaseSettings):
|
|||||||
alias="ADMIN_IDS",
|
alias="ADMIN_IDS",
|
||||||
description="Comma-separated list of admin Telegram User IDs")
|
description="Comma-separated list of admin Telegram User IDs")
|
||||||
|
|
||||||
POSTGRES_USER: str = Field(default="user")
|
POSTGRES_USER: str = Field(...)
|
||||||
POSTGRES_PASSWORD: str = Field(default="password")
|
POSTGRES_PASSWORD: str = Field(...)
|
||||||
POSTGRES_HOST: str = Field(default="localhost")
|
POSTGRES_HOST: str = Field(default="localhost")
|
||||||
POSTGRES_PORT: int = Field(default=5432)
|
POSTGRES_PORT: int = Field(default=5432)
|
||||||
POSTGRES_DB: str = Field(default="vpn_shop_db")
|
POSTGRES_DB: str = Field(default="vpn_shop_db")
|
||||||
@@ -75,6 +164,10 @@ class Settings(BaseSettings):
|
|||||||
)
|
)
|
||||||
|
|
||||||
WEBHOOK_BASE_URL: Optional[str] = None
|
WEBHOOK_BASE_URL: Optional[str] = None
|
||||||
|
TRUSTED_PROXIES: Optional[str] = Field(
|
||||||
|
default="127.0.0.1,::1",
|
||||||
|
description="Comma-separated list of reverse proxy IPs or CIDRs trusted to forward X-Forwarded-For.",
|
||||||
|
)
|
||||||
|
|
||||||
CRYPTOPAY_TOKEN: Optional[str] = None
|
CRYPTOPAY_TOKEN: Optional[str] = None
|
||||||
CRYPTOPAY_NETWORK: str = Field(default="mainnet")
|
CRYPTOPAY_NETWORK: str = Field(default="mainnet")
|
||||||
@@ -99,6 +192,10 @@ class Settings(BaseSettings):
|
|||||||
FREEKASSA_API_KEY: Optional[str] = None
|
FREEKASSA_API_KEY: Optional[str] = None
|
||||||
FREEKASSA_PAYMENT_IP: Optional[str] = None
|
FREEKASSA_PAYMENT_IP: Optional[str] = None
|
||||||
FREEKASSA_PAYMENT_METHOD_ID: Optional[int] = None
|
FREEKASSA_PAYMENT_METHOD_ID: Optional[int] = None
|
||||||
|
FREEKASSA_TRUSTED_IPS: str = Field(
|
||||||
|
default="168.119.157.136,168.119.60.227,178.154.197.79,51.250.54.238",
|
||||||
|
description="Comma-separated FreeKassa webhook IP allowlist.",
|
||||||
|
)
|
||||||
|
|
||||||
SEVERPAY_ENABLED: bool = Field(default=False)
|
SEVERPAY_ENABLED: bool = Field(default=False)
|
||||||
SEVERPAY_MID: Optional[int] = None
|
SEVERPAY_MID: Optional[int] = None
|
||||||
@@ -211,7 +308,9 @@ class Settings(BaseSettings):
|
|||||||
WEBAPP_TITLE: str = Field(default="Моя подписка")
|
WEBAPP_TITLE: str = Field(default="Моя подписка")
|
||||||
WEBAPP_PRIMARY_COLOR: str = Field(default="#00fe7a")
|
WEBAPP_PRIMARY_COLOR: str = Field(default="#00fe7a")
|
||||||
WEBAPP_LOGO_URL: Optional[str] = Field(default=None)
|
WEBAPP_LOGO_URL: Optional[str] = Field(default=None)
|
||||||
WEBAPP_SESSION_TTL_SECONDS: int = Field(default=30 * 24 * 60 * 60)
|
WEBAPP_SESSION_SECRET: str = Field(default_factory=lambda: secrets.token_urlsafe(32))
|
||||||
|
WEBHOOK_SECRET_TOKEN: str = Field(default_factory=lambda: secrets.token_urlsafe(32))
|
||||||
|
WEBAPP_SESSION_TTL_SECONDS: int = Field(default=24 * 60 * 60)
|
||||||
WEBAPP_AUTH_MAX_AGE_SECONDS: int = Field(default=24 * 60 * 60)
|
WEBAPP_AUTH_MAX_AGE_SECONDS: int = Field(default=24 * 60 * 60)
|
||||||
WEBAPP_LOGIN_TOKEN_TTL_SECONDS: int = Field(default=10 * 60)
|
WEBAPP_LOGIN_TOKEN_TTL_SECONDS: int = Field(default=10 * 60)
|
||||||
|
|
||||||
@@ -268,6 +367,98 @@ class Settings(BaseSettings):
|
|||||||
def DATABASE_URL(self) -> str:
|
def DATABASE_URL(self) -> str:
|
||||||
return f"postgresql+asyncpg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
|
return f"postgresql+asyncpg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def db_settings(self) -> DBSettings:
|
||||||
|
return DBSettings(
|
||||||
|
user=self.POSTGRES_USER,
|
||||||
|
password=self.POSTGRES_PASSWORD,
|
||||||
|
host=self.POSTGRES_HOST,
|
||||||
|
port=self.POSTGRES_PORT,
|
||||||
|
database=self.POSTGRES_DB,
|
||||||
|
)
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def payment_settings(self) -> PaymentSettings:
|
||||||
|
return PaymentSettings(
|
||||||
|
yookassa_enabled=self.YOOKASSA_ENABLED,
|
||||||
|
yookassa_shop_id=self.YOOKASSA_SHOP_ID,
|
||||||
|
yookassa_secret_key=self.YOOKASSA_SECRET_KEY,
|
||||||
|
yookassa_return_url=self.YOOKASSA_RETURN_URL,
|
||||||
|
yookassa_default_receipt_email=self.YOOKASSA_DEFAULT_RECEIPT_EMAIL,
|
||||||
|
yookassa_vat_code=self.YOOKASSA_VAT_CODE,
|
||||||
|
yookassa_payment_mode=self.YOOKASSA_PAYMENT_MODE,
|
||||||
|
yookassa_payment_subject=self.YOOKASSA_PAYMENT_SUBJECT,
|
||||||
|
yookassa_autopayments_enabled=self.YOOKASSA_AUTOPAYMENTS_ENABLED,
|
||||||
|
yookassa_autopayments_require_card_binding=self.YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING,
|
||||||
|
freekassa_enabled=self.FREEKASSA_ENABLED,
|
||||||
|
freekassa_merchant_id=self.FREEKASSA_MERCHANT_ID,
|
||||||
|
freekassa_second_secret=self.FREEKASSA_SECOND_SECRET,
|
||||||
|
freekassa_api_key=self.FREEKASSA_API_KEY,
|
||||||
|
freekassa_payment_ip=self.FREEKASSA_PAYMENT_IP,
|
||||||
|
freekassa_payment_method_id=self.FREEKASSA_PAYMENT_METHOD_ID,
|
||||||
|
freekassa_trusted_ips=self.freekassa_trusted_ips,
|
||||||
|
platega_enabled=self.PLATEGA_ENABLED,
|
||||||
|
platega_base_url=self.PLATEGA_BASE_URL,
|
||||||
|
platega_merchant_id=self.PLATEGA_MERCHANT_ID,
|
||||||
|
platega_secret=self.PLATEGA_SECRET,
|
||||||
|
platega_payment_method=self.PLATEGA_PAYMENT_METHOD,
|
||||||
|
platega_return_url=self.PLATEGA_RETURN_URL,
|
||||||
|
platega_failed_url=self.PLATEGA_FAILED_URL,
|
||||||
|
severpay_enabled=self.SEVERPAY_ENABLED,
|
||||||
|
severpay_mid=self.SEVERPAY_MID,
|
||||||
|
severpay_token=self.SEVERPAY_TOKEN,
|
||||||
|
severpay_return_url=self.SEVERPAY_RETURN_URL,
|
||||||
|
severpay_base_url=self.SEVERPAY_BASE_URL,
|
||||||
|
severpay_lifetime_minutes=self.SEVERPAY_LIFETIME_MINUTES,
|
||||||
|
cryptopay_enabled=self.CRYPTOPAY_ENABLED,
|
||||||
|
cryptopay_token=self.CRYPTOPAY_TOKEN,
|
||||||
|
cryptopay_network=self.CRYPTOPAY_NETWORK,
|
||||||
|
cryptopay_currency_type=self.CRYPTOPAY_CURRENCY_TYPE,
|
||||||
|
cryptopay_asset=self.CRYPTOPAY_ASSET,
|
||||||
|
)
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def email_settings(self) -> EmailSettings:
|
||||||
|
return EmailSettings(
|
||||||
|
smtp_host=self.SMTP_HOST,
|
||||||
|
smtp_port=self.SMTP_PORT,
|
||||||
|
smtp_fallback_ports=self.SMTP_FALLBACK_PORTS,
|
||||||
|
smtp_timeout_seconds=self.SMTP_TIMEOUT_SECONDS,
|
||||||
|
smtp_username=self.SMTP_USERNAME,
|
||||||
|
smtp_password=self.SMTP_PASSWORD,
|
||||||
|
smtp_from_email=self.SMTP_FROM_EMAIL,
|
||||||
|
smtp_from_name=self.SMTP_FROM_NAME,
|
||||||
|
smtp_starttls=self.SMTP_STARTTLS,
|
||||||
|
smtp_use_ssl=self.SMTP_USE_SSL,
|
||||||
|
email_code_ttl_seconds=self.EMAIL_CODE_TTL_SECONDS,
|
||||||
|
email_code_resend_seconds=self.EMAIL_CODE_RESEND_SECONDS,
|
||||||
|
email_code_max_attempts=self.EMAIL_CODE_MAX_ATTEMPTS,
|
||||||
|
brute_force_max_failures=self.BRUTE_FORCE_MAX_FAILURES,
|
||||||
|
brute_force_window_seconds=self.BRUTE_FORCE_WINDOW_SECONDS,
|
||||||
|
brute_force_lock_seconds=self.BRUTE_FORCE_LOCK_SECONDS,
|
||||||
|
)
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def webapp_settings(self) -> WebAppSettings:
|
||||||
|
return WebAppSettings(
|
||||||
|
title=self.WEBAPP_TITLE,
|
||||||
|
primary_color=self.WEBAPP_PRIMARY_COLOR,
|
||||||
|
logo_url=self.WEBAPP_LOGO_URL,
|
||||||
|
session_ttl_seconds=self.WEBAPP_SESSION_TTL_SECONDS,
|
||||||
|
session_secret=self.WEBAPP_SESSION_SECRET,
|
||||||
|
webhook_secret_token=self.WEBHOOK_SECRET_TOKEN,
|
||||||
|
auth_max_age_seconds=self.WEBAPP_AUTH_MAX_AGE_SECONDS,
|
||||||
|
login_token_ttl_seconds=self.WEBAPP_LOGIN_TOKEN_TTL_SECONDS,
|
||||||
|
server_host=self.WEBAPP_SERVER_HOST,
|
||||||
|
server_port=self.WEBAPP_SERVER_PORT,
|
||||||
|
enabled=self.WEBAPP_ENABLED,
|
||||||
|
trusted_proxies=self.trusted_proxies,
|
||||||
|
)
|
||||||
|
|
||||||
@computed_field
|
@computed_field
|
||||||
@property
|
@property
|
||||||
def ADMIN_IDS(self) -> List[int]:
|
def ADMIN_IDS(self) -> List[int]:
|
||||||
@@ -325,6 +516,21 @@ class Settings(BaseSettings):
|
|||||||
return cleaned
|
return cleaned
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def trusted_proxies(self) -> List[str]:
|
||||||
|
return _split_csv(self.TRUSTED_PROXIES)
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def freekassa_trusted_ips(self) -> List[str]:
|
||||||
|
return _split_csv(self.FREEKASSA_TRUSTED_IPS)
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def telegram_webhook_path(self) -> str:
|
||||||
|
return "/tg/webhook"
|
||||||
|
|
||||||
@computed_field
|
@computed_field
|
||||||
@property
|
@property
|
||||||
def yookassa_webhook_path(self) -> str:
|
def yookassa_webhook_path(self) -> str:
|
||||||
@@ -605,6 +811,26 @@ class Settings(BaseSettings):
|
|||||||
return "INFO"
|
return "INFO"
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
@field_validator('POSTGRES_USER', 'POSTGRES_PASSWORD', mode='before')
|
||||||
|
@classmethod
|
||||||
|
def validate_required_db_credentials(cls, v):
|
||||||
|
if isinstance(v, str):
|
||||||
|
v = v.strip()
|
||||||
|
if not v:
|
||||||
|
raise ValueError("must not be empty")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator('WEBAPP_SESSION_SECRET', 'WEBHOOK_SECRET_TOKEN', mode='before')
|
||||||
|
@classmethod
|
||||||
|
def normalize_webapp_secrets(cls, v):
|
||||||
|
if isinstance(v, str):
|
||||||
|
v = v.strip()
|
||||||
|
if v:
|
||||||
|
return v
|
||||||
|
if v:
|
||||||
|
return v
|
||||||
|
return secrets.token_urlsafe(32)
|
||||||
|
|
||||||
@field_validator('LOG_CHAT_ID', 'LOG_THREAD_ID', mode='before')
|
@field_validator('LOG_CHAT_ID', 'LOG_THREAD_ID', mode='before')
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_optional_int_fields(cls, v):
|
def validate_optional_int_fields(cls, v):
|
||||||
@@ -675,6 +901,14 @@ def get_settings() -> Settings:
|
|||||||
logging.warning(
|
logging.warning(
|
||||||
"CRITICAL: PANEL_API_URL is not set. Panel integration will not work."
|
"CRITICAL: PANEL_API_URL is not set. Panel integration will not work."
|
||||||
)
|
)
|
||||||
|
if not os.getenv("WEBAPP_SESSION_SECRET"):
|
||||||
|
logging.warning(
|
||||||
|
"WEBAPP_SESSION_SECRET is not set. A generated secret will be used for this process only."
|
||||||
|
)
|
||||||
|
if not os.getenv("WEBHOOK_SECRET_TOKEN"):
|
||||||
|
logging.warning(
|
||||||
|
"WEBHOOK_SECRET_TOKEN is not set. A generated secret will be used for this process only."
|
||||||
|
)
|
||||||
if not _settings_instance.YOOKASSA_SHOP_ID or not _settings_instance.YOOKASSA_SECRET_KEY:
|
if not _settings_instance.YOOKASSA_SHOP_ID or not _settings_instance.YOOKASSA_SECRET_KEY:
|
||||||
logging.warning(
|
logging.warning(
|
||||||
"CRITICAL: YooKassa credentials (SHOP_ID or SECRET_KEY) are not set. Payments will not work."
|
"CRITICAL: YooKassa credentials (SHOP_ID or SECRET_KEY) are not set. Payments will not work."
|
||||||
|
|||||||
@@ -11,22 +11,10 @@ from db.database_setup import init_db, init_db_connection
|
|||||||
|
|
||||||
|
|
||||||
def _resolve_log_level(value: str) -> int:
|
def _resolve_log_level(value: str) -> int:
|
||||||
if not value:
|
return getattr(logging, value.upper(), logging.INFO)
|
||||||
return logging.INFO
|
|
||||||
if isinstance(value, str):
|
|
||||||
normalized = value.strip()
|
|
||||||
if not normalized:
|
|
||||||
return logging.INFO
|
|
||||||
if normalized.isdigit():
|
|
||||||
return int(normalized)
|
|
||||||
level = getattr(logging, normalized.upper(), None)
|
|
||||||
if isinstance(level, int):
|
|
||||||
return level
|
|
||||||
return logging.INFO
|
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
load_dotenv()
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
session_factory = init_db_connection(settings)
|
session_factory = init_db_connection(settings)
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
from bot.handlers.user.payment import yookassa_webhook_route
|
||||||
|
from bot.services.freekassa_service import FreeKassaService
|
||||||
|
from bot.utils.request_security import request_client_ip
|
||||||
|
|
||||||
|
|
||||||
|
class RequestSecurityTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_request_client_ip_uses_last_forwarded_for_value_for_trusted_proxy(self):
|
||||||
|
request = SimpleNamespace(
|
||||||
|
remote="127.0.0.1",
|
||||||
|
headers={"X-Forwarded-For": "203.0.113.10, 198.51.100.7"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
request_client_ip(request, trusted_proxies=["127.0.0.1"]),
|
||||||
|
"198.51.100.7",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_yookassa_webhook_rejects_untrusted_ip_before_reading_body(self):
|
||||||
|
request = SimpleNamespace(
|
||||||
|
app={
|
||||||
|
"bot": object(),
|
||||||
|
"i18n": object(),
|
||||||
|
"settings": SimpleNamespace(trusted_proxies=["127.0.0.1"]),
|
||||||
|
"panel_service": object(),
|
||||||
|
"subscription_service": object(),
|
||||||
|
"referral_service": object(),
|
||||||
|
"lknpd_service": None,
|
||||||
|
"async_session_factory": object(),
|
||||||
|
},
|
||||||
|
headers={},
|
||||||
|
remote="203.0.113.50",
|
||||||
|
json=AsyncMock(side_effect=AssertionError("request.json() must not be called")),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await yookassa_webhook_route(request)
|
||||||
|
|
||||||
|
self.assertEqual(response.status, 403)
|
||||||
|
request.json.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
class FreeKassaServiceTests(unittest.TestCase):
|
||||||
|
def _make_service(self) -> FreeKassaService:
|
||||||
|
settings = SimpleNamespace(
|
||||||
|
FREEKASSA_ENABLED=True,
|
||||||
|
FREEKASSA_MERCHANT_ID="123456",
|
||||||
|
FREEKASSA_API_KEY="api-key",
|
||||||
|
FREEKASSA_SECOND_SECRET="second-secret",
|
||||||
|
DEFAULT_CURRENCY_SYMBOL="RUB",
|
||||||
|
FREEKASSA_PAYMENT_IP="203.0.113.10",
|
||||||
|
FREEKASSA_PAYMENT_METHOD_ID=44,
|
||||||
|
FREEKASSA_TRUSTED_IPS="127.0.0.1,203.0.113.0/24",
|
||||||
|
trusted_proxies=["127.0.0.1"],
|
||||||
|
freekassa_trusted_ips=["127.0.0.1", "203.0.113.0/24"],
|
||||||
|
)
|
||||||
|
return FreeKassaService(
|
||||||
|
bot=object(),
|
||||||
|
settings=settings,
|
||||||
|
i18n=object(),
|
||||||
|
async_session_factory=object(),
|
||||||
|
subscription_service=object(),
|
||||||
|
referral_service=object(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_validate_signature_accepts_hmac_sha256_raw_body(self):
|
||||||
|
service = self._make_service()
|
||||||
|
raw_body = b'{"amount":"199.00","o":"42"}'
|
||||||
|
expected_signature = hmac.new(
|
||||||
|
service.second_secret.encode("utf-8"),
|
||||||
|
raw_body,
|
||||||
|
hashlib.sha256,
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
self.assertTrue(service._validate_signature(raw_body, expected_signature))
|
||||||
|
|
||||||
|
def test_validate_signature_rejects_wrong_signature(self):
|
||||||
|
service = self._make_service()
|
||||||
|
|
||||||
|
self.assertFalse(service._validate_signature(b"payload", "not-a-signature"))
|
||||||
|
|
||||||
|
def test_webhook_rejects_unauthorized_ip_before_body_read(self):
|
||||||
|
service = self._make_service()
|
||||||
|
request = SimpleNamespace(
|
||||||
|
remote="198.51.100.250",
|
||||||
|
headers={},
|
||||||
|
read=AsyncMock(side_effect=AssertionError("request.read() must not be called")),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = asyncio_run(service.webhook_route(request))
|
||||||
|
|
||||||
|
self.assertEqual(response.status, 403)
|
||||||
|
request.read.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
def asyncio_run(coro):
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
return asyncio.run(coro)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from config.settings import Settings
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsTests(unittest.TestCase):
|
||||||
|
def test_blank_postgres_password_is_rejected(self):
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
Settings(
|
||||||
|
_env_file=None,
|
||||||
|
BOT_TOKEN="token",
|
||||||
|
POSTGRES_USER="app_user",
|
||||||
|
POSTGRES_PASSWORD="",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_webapp_secrets_are_generated_when_missing(self):
|
||||||
|
settings = Settings(
|
||||||
|
_env_file=None,
|
||||||
|
BOT_TOKEN="token",
|
||||||
|
POSTGRES_USER="app_user",
|
||||||
|
POSTGRES_PASSWORD="app_password",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(settings.WEBAPP_SESSION_SECRET)
|
||||||
|
self.assertTrue(settings.WEBHOOK_SECRET_TOKEN)
|
||||||
|
self.assertEqual(settings.WEBAPP_SESSION_TTL_SECONDS, 86400)
|
||||||
Reference in New Issue
Block a user