Skip to content
2 changes: 1 addition & 1 deletion src/__version__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.8.0"
__version__ = "0.8.1"
8 changes: 8 additions & 0 deletions src/application/dto/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,14 @@ class UserDto(BaseDto, TrackableMixin, TimestampMixin):
ad_link_id: Optional[int] = None
referral_code_reset_at: Optional[datetime] = None

@property
def contact_label(self) -> str:
if self.username:
return self.username
if self.telegram_id is not None:
return str(self.telegram_id)
return self.email or str(self.id)

@property
def is_privileged(self) -> bool:
return self.role.includes(Role.ADMIN)
Expand Down
3 changes: 2 additions & 1 deletion src/application/use_cases/auth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,14 @@
from .commands.password import ChangePassword
from .commands.register import RegisterEmailUser
from .commands.session import RefreshSession
from .commands.telegram import AuthenticateTelegram, LinkTelegram
from .commands.telegram import AuthenticateTelegram, AuthenticateTelegramWebApp, LinkTelegram

AUTH_USE_CASES: Final[tuple[type[Interactor], ...]] = (
RegisterEmailUser,
LoginEmailUser,
RefreshSession,
AuthenticateTelegram,
AuthenticateTelegramWebApp,
LinkTelegram,
ChangePassword,
ChangeEmail,
Expand Down
25 changes: 25 additions & 0 deletions src/application/use_cases/auth/_telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import hmac
import time
from typing import Any
from urllib.parse import parse_qsl

from src.core.constants import TELEGRAM_AUTH_MAX_AGE_SECONDS

Expand All @@ -22,3 +23,27 @@ def verify_telegram_auth(data: dict[str, Any], bot_token: str) -> bool:
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, telegram_hash)


def parse_webapp_init_data(init_data: str) -> dict[str, str]:
return dict(parse_qsl(init_data, keep_blank_values=True))


def verify_telegram_webapp_init_data(init_data: str, bot_token: str) -> bool:
fields = parse_webapp_init_data(init_data)
telegram_hash = fields.pop("hash", "")
if not telegram_hash:
return False

auth_date = int(fields.get("auth_date", 0))
if int(time.time()) - auth_date > TELEGRAM_AUTH_MAX_AGE_SECONDS:
return False

data_check_string = "\n".join(f"{k}={fields[k]}" for k in sorted(fields))
secret_key = hmac.new(b"WebAppData", bot_token.encode("utf-8"), hashlib.sha256).digest()
expected = hmac.new(
secret_key,
data_check_string.encode("utf-8"),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, telegram_hash)
109 changes: 83 additions & 26 deletions src/application/use_cases/auth/commands/telegram.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
from dataclasses import dataclass
from typing import Any

Expand All @@ -9,7 +10,11 @@
from src.application.common.policy import Permission
from src.application.common.uow import UnitOfWork
from src.application.dto import UserDto
from src.application.use_cases.auth._telegram import verify_telegram_auth
from src.application.use_cases.auth._telegram import (
parse_webapp_init_data,
verify_telegram_auth,
verify_telegram_webapp_init_data,
)
from src.application.use_cases.user.commands.web_registration import (
RegisterWebUser,
RegisterWebUserDto,
Expand All @@ -27,6 +32,41 @@ class TelegramAuthData:
payload: dict[str, Any]


async def _get_or_create_telegram_user(
user_dao: UserDao,
register_web_user: RegisterWebUser,
config: AppConfig,
data: TelegramAuthData,
) -> UserDto:
user = await user_dao.get_by_telegram_id(data.id)
if user:
if user.is_blocked:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
return user

name_parts = [data.first_name]
if data.last_name:
name_parts.append(data.last_name)

new_user = UserDto(
telegram_id=data.id,
auth_type=AuthType.TELEGRAM,
username=data.username,
name=" ".join(name_parts),
language=config.default_locale,
)

try:
return await register_web_user.system(RegisterWebUserDto(user=new_user))
except IntegrityError as e:
existing = await user_dao.get_by_telegram_id(data.id)
if existing:
return existing
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="User creation conflict"
) from e


class AuthenticateTelegram(Interactor[TelegramAuthData, UserDto]):
required_permission = None

Expand All @@ -47,34 +87,51 @@ async def _execute(self, actor: UserDto, data: TelegramAuthData) -> UserDto:
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid Telegram auth data",
)

user = await self.user_dao.get_by_telegram_id(data.id)
if user:
if user.is_blocked:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
return user

name_parts = [data.first_name]
if data.last_name:
name_parts.append(data.last_name)

new_user = UserDto(
telegram_id=data.id,
auth_type=AuthType.TELEGRAM,
username=data.username,
name=" ".join(name_parts),
language=self.config.default_locale,
return await _get_or_create_telegram_user(
self.user_dao, self.register_web_user, self.config, data
)

try:
return await self.register_web_user.system(RegisterWebUserDto(user=new_user))
except IntegrityError as e:
existing = await self.user_dao.get_by_telegram_id(data.id)
if existing:
return existing

class AuthenticateTelegramWebApp(Interactor[str, UserDto]):
required_permission = None

def __init__(
self,
config: AppConfig,
user_dao: UserDao,
register_web_user: RegisterWebUser,
) -> None:
self.config = config
self.user_dao = user_dao
self.register_web_user = register_web_user

async def _execute(self, actor: UserDto, data: str) -> UserDto:
bot_token = self.config.bot.token.get_secret_value()
if not verify_telegram_webapp_init_data(data, bot_token):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="User creation conflict"
) from e
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid Telegram WebApp init data",
)

fields = parse_webapp_init_data(data)
raw_user = fields.get("user")
if not raw_user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing user in init data",
)
user_payload = json.loads(raw_user)

auth_data = TelegramAuthData(
id=int(user_payload["id"]),
first_name=str(user_payload.get("first_name", "")),
last_name=user_payload.get("last_name"),
username=user_payload.get("username"),
payload=user_payload,
)
return await _get_or_create_telegram_user(
self.user_dao, self.register_web_user, self.config, auth_data
)


@dataclass
Expand Down
10 changes: 5 additions & 5 deletions src/application/use_cases/user/commands/profile_edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ async def _execute(self, actor: UserDto, data: SetUserPersonalDiscountDto) -> No
if not target_user:
raise ValueError(f"User '{data.user_id}' not found")

if not actor.role > target_user.role:
if actor.id != target_user.id and not actor.role > target_user.role:
logger.warning(
f"{actor.log} denied editing user '{target_user.id}': "
f"target role '{target_user.role}' >= actor role '{actor.role}'"
Expand Down Expand Up @@ -73,7 +73,7 @@ async def _execute(self, actor: UserDto, data: SetUserPurchaseDiscountDto) -> No
if not target_user:
raise ValueError(f"User '{data.user_id}' not found")

if not actor.role > target_user.role:
if actor.id != target_user.id and not actor.role > target_user.role:
logger.warning(
f"{actor.log} denied editing user '{target_user.id}': "
f"target role '{target_user.role}' >= actor role '{actor.role}'"
Expand Down Expand Up @@ -102,7 +102,7 @@ async def _execute(self, actor: UserDto, user_id: int) -> None:
if not target_user:
raise ValueError(f"User '{user_id}' not found")

if not actor.role > target_user.role:
if actor.id != target_user.id and not actor.role > target_user.role:
logger.warning(
f"{actor.log} denied editing user '{target_user.id}': "
f"target role '{target_user.role}' >= actor role '{actor.role}'"
Expand Down Expand Up @@ -136,7 +136,7 @@ async def _execute(self, actor: UserDto, data: ChangeUserPointsDto) -> None:
logger.error(f"{actor.log} User not found with id '{data.user_id}'")
raise ValueError(f"User '{data.user_id}' not found")

if not actor.role > target_user.role:
if actor.id != target_user.id and not actor.role > target_user.role:
logger.warning(
f"{actor.log} denied editing user '{target_user.id}': "
f"target role '{target_user.role}' >= actor role '{actor.role}'"
Expand Down Expand Up @@ -173,7 +173,7 @@ async def _execute(self, actor: UserDto, user_id: int) -> None:
if not target_user:
raise ValueError(f"User '{user_id}' not found")

if not actor.role > target_user.role:
if actor.id != target_user.id and not actor.role > target_user.role:
logger.warning(
f"{actor.log} denied editing user '{target_user.id}': "
f"target role '{target_user.role}' >= actor role '{actor.role}'"
Expand Down
2 changes: 2 additions & 0 deletions src/core/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@
TARGET_TELEGRAM_ID: Final[str] = "target_telegram_id"
TARGET_USER_ID: Final[str] = "target_user_id"
FROM_REFERRAL_USER_ID: Final[str] = "from_referral_user_id"
USER_LIST_ORIGIN: Final[str] = "user_list_origin"
USER_LIST_PAYLOAD: Final[str] = "user_list_payload"

INT32_MAX: Final[int] = 2_147_483_647

Expand Down
55 changes: 15 additions & 40 deletions src/infrastructure/payment_gateways/valutix.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
import base64
import hashlib
import hmac
import uuid
from decimal import Decimal
from typing import Any, Final, Optional, Union
from typing import Any, Final, Union
from uuid import UUID

import orjson
from aiogram import Bot
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from fastapi import Request
from httpx import AsyncClient, HTTPStatusError
from loguru import logger
Expand All @@ -24,7 +22,6 @@
# https://docs.panel.valutix.kz/ru/docs
class ValutixGateway(BasePaymentGateway):
_client: AsyncClient
_public_key_pem: Optional[str]

API_BASE: Final[str] = "https://api.panel.valutix.kz"

Expand All @@ -37,7 +34,6 @@ def __init__(self, gateway: PaymentGatewayDto, bot: Bot, config: AppConfig) -> N
f"got {type(self.data.settings).__name__}"
)

self._public_key_pem = None
self._client = self._make_client(
base_url=self.API_BASE,
headers={"X-Api-Token": self.data.settings.api_key.get_secret_value()}, # type: ignore[union-attr]
Expand Down Expand Up @@ -83,9 +79,9 @@ async def handle_webhook(self, request: Request) -> Union[tuple[UUID, Transactio
webhook_data = orjson.loads(raw_body)
logger.debug(f"Valutix webhook data: {webhook_data}")

payment_id_str = webhook_data.get("uuid")
payment_id_str = webhook_data.get("id")
if not payment_id_str:
raise ValueError("Required field 'uuid' is missing")
raise ValueError("Required field 'id' is missing")

status = webhook_data.get("status")
payment_id = UUID(payment_id_str)
Expand Down Expand Up @@ -114,38 +110,17 @@ def _get_payment_data(self, data: dict[str, Any]) -> PaymentResultDto:

return PaymentResultDto(id=UUID(valutix_id), url=str(payment_link))

async def _fetch_public_key(self) -> str:
response = await self._client.get("v1/orders/pubkey")
response.raise_for_status()
result: str = orjson.loads(response.content)
return result

async def _get_public_key(self) -> str:
if self._public_key_pem is None:
self._public_key_pem = await self._fetch_public_key()
logger.debug("Fetched Valutix RSA public key")
return self._public_key_pem

async def _verify_webhook(self, request: Request, raw_body: bytes) -> None:
signature_b64 = request.headers.get("X-Signature")
if not signature_b64:
signature = request.headers.get("X-Signature")
if not signature:
raise PermissionError("Valutix webhook missing X-Signature header")

try:
signature = base64.b64decode(signature_b64)
except Exception:
raise PermissionError("Valutix webhook X-Signature is not valid base64")

pubkey_pem = await self._get_public_key()
try:
public_key = serialization.load_pem_public_key(pubkey_pem.encode())
public_key.verify(signature, raw_body, padding.PKCS1v15(), hashes.SHA256()) # type: ignore[union-attr,arg-type,call-arg]
except InvalidSignature:
self._public_key_pem = None
logger.warning("Valutix webhook RSA signature verification failed")
raise PermissionError("Valutix webhook verification failed")
except PermissionError:
raise
except Exception as e:
logger.error(f"Valutix webhook verification error: {e}")
api_key = self.data.settings.api_key.get_secret_value() # type: ignore[union-attr]
expected_signature = hmac.new(
api_key.encode(),
raw_body,
hashlib.sha512,
).hexdigest()
if not hmac.compare_digest(signature.lower(), expected_signature):
logger.warning("Valutix webhook HMAC signature verification failed")
raise PermissionError("Valutix webhook verification failed")
4 changes: 4 additions & 0 deletions src/telegram/dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from src.telegram.filters import setup_global_filters
from src.telegram.message_manager import MessageManager
from src.telegram.middlewares import setup_middlewares
from src.telegram.middlewares.user import UserMiddleware
from src.telegram.routers import setup_routers


Expand Down Expand Up @@ -50,5 +51,8 @@ def setup_dispatcher(dispatcher: Dispatcher) -> None:


def setup_worker_dispatcher(dispatcher: Dispatcher) -> None:
# Background redirects (e.g. Subscription:SUCCESS) start dialogs via bg_manager, which
# emit AIOGD_UPDATE. Dialog getters rely on USER_KEY, so UserMiddleware must populate it.
UserMiddleware().setup_outer(dispatcher)
setup_routers(dispatcher)
logger.info("Worker dispatcher routers have been configured")
15 changes: 7 additions & 8 deletions src/telegram/routers/dashboard/remnashop/menu_editor/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,14 +207,13 @@ async def on_payload_input( # noqa: C901
user, UpdateMenuButtonMediaDto(button, file_id, media_type)
)

if text:
try:
button = await update_menu_button_payload(
user, UpdateMenuButtonPayloadDto(button, text)
)
except ValueError:
await notifier.notify_user(user, i18n_key="ntf-common.invalid-value")
return
try:
button = await update_menu_button_payload(
user, UpdateMenuButtonPayloadDto(button, text)
)
except ValueError:
await notifier.notify_user(user, i18n_key="ntf-common.invalid-value")
return
else:
if message.text is None:
await notifier.notify_user(user, i18n_key="ntf-common.invalid-value")
Expand Down
Loading
Loading