mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-04-28 08:41:05 +00:00
Merge pull request #857 from Fr1ngg/m9d0zq-bedolaga/-api
Add user API tokens and verification endpoint
This commit is contained in:
@@ -255,6 +255,7 @@ class Settings(BaseSettings):
|
||||
WEB_API_DEFAULT_TOKEN: Optional[str] = None
|
||||
WEB_API_DEFAULT_TOKEN_NAME: str = "Bootstrap Token"
|
||||
WEB_API_TOKEN_HASH_ALGORITHM: str = "sha256"
|
||||
USER_API_TOKEN_HASH_ALGORITHM: str = "sha256"
|
||||
WEB_API_REQUEST_LOGGING: bool = True
|
||||
|
||||
APP_CONFIG_PATH: str = "app-config.json"
|
||||
|
||||
@@ -39,6 +39,7 @@ async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]:
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.api_token),
|
||||
)
|
||||
.where(User.id == user_id)
|
||||
)
|
||||
@@ -56,6 +57,7 @@ async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> Optiona
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.api_token),
|
||||
)
|
||||
.where(User.telegram_id == telegram_id)
|
||||
)
|
||||
@@ -70,7 +72,10 @@ async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> Optiona
|
||||
async def get_user_by_referral_code(db: AsyncSession, referral_code: str) -> Optional[User]:
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(selectinload(User.promo_group))
|
||||
.options(
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.api_token),
|
||||
)
|
||||
.where(User.referral_code == referral_code)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
88
app/database/crud/user_api_token.py
Normal file
88
app/database/crud/user_api_token.py
Normal file
@@ -0,0 +1,88 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import UserApiToken
|
||||
|
||||
|
||||
async def get_token_by_user_id(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
) -> Optional[UserApiToken]:
|
||||
query = select(UserApiToken).where(UserApiToken.user_id == user_id)
|
||||
result = await db.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_token_by_hash(
|
||||
db: AsyncSession,
|
||||
token_hash: str,
|
||||
) -> Optional[UserApiToken]:
|
||||
query = select(UserApiToken).where(UserApiToken.token_hash == token_hash)
|
||||
result = await db.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_token(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
token_hash: str,
|
||||
token_prefix: str,
|
||||
token_last_digits: str,
|
||||
) -> UserApiToken:
|
||||
token = UserApiToken(
|
||||
user_id=user_id,
|
||||
token_hash=token_hash,
|
||||
token_prefix=token_prefix,
|
||||
token_last_digits=token_last_digits,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(token)
|
||||
await db.flush()
|
||||
await db.refresh(token)
|
||||
return token
|
||||
|
||||
|
||||
async def update_token(
|
||||
db: AsyncSession,
|
||||
token: UserApiToken,
|
||||
*,
|
||||
token_hash: str,
|
||||
token_prefix: str,
|
||||
token_last_digits: str,
|
||||
) -> UserApiToken:
|
||||
token.token_hash = token_hash
|
||||
token.token_prefix = token_prefix
|
||||
token.token_last_digits = token_last_digits
|
||||
token.is_active = True
|
||||
token.updated_at = datetime.utcnow()
|
||||
token.last_used_at = None
|
||||
token.last_used_ip = None
|
||||
await db.flush()
|
||||
await db.refresh(token)
|
||||
return token
|
||||
|
||||
|
||||
async def deactivate_token(
|
||||
db: AsyncSession,
|
||||
token: UserApiToken,
|
||||
) -> UserApiToken:
|
||||
token.is_active = False
|
||||
token.updated_at = datetime.utcnow()
|
||||
await db.flush()
|
||||
await db.refresh(token)
|
||||
return token
|
||||
|
||||
|
||||
__all__ = [
|
||||
"get_token_by_user_id",
|
||||
"get_token_by_hash",
|
||||
"create_token",
|
||||
"update_token",
|
||||
"deactivate_token",
|
||||
]
|
||||
@@ -400,6 +400,12 @@ class User(Base):
|
||||
has_made_first_topup: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="RESTRICT"), nullable=False, index=True)
|
||||
promo_group = relationship("PromoGroup", back_populates="users")
|
||||
api_token = relationship(
|
||||
"UserApiToken",
|
||||
back_populates="user",
|
||||
uselist=False,
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
@property
|
||||
def balance_rubles(self) -> float:
|
||||
@@ -1252,4 +1258,34 @@ class WebApiToken(Base):
|
||||
|
||||
def __repr__(self) -> str:
|
||||
status = "active" if self.is_active else "revoked"
|
||||
return f"<WebApiToken id={self.id} name='{self.name}' status={status}>"
|
||||
return f"<WebApiToken id={self.id} name='{self.name}' status={status}>"
|
||||
|
||||
|
||||
class UserApiToken(Base):
|
||||
__tablename__ = "user_api_tokens"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(
|
||||
Integer,
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
unique=True,
|
||||
index=True,
|
||||
)
|
||||
token_hash = Column(String(128), nullable=False, unique=True, index=True)
|
||||
token_prefix = Column(String(32), nullable=False)
|
||||
token_last_digits = Column(String(16), nullable=False)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
last_used_at = Column(DateTime, nullable=True)
|
||||
last_used_ip = Column(String(64), nullable=True)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
|
||||
user = relationship("User", back_populates="api_token")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
status = "active" if self.is_active else "revoked"
|
||||
return (
|
||||
f"<UserApiToken id={self.id} user_id={self.user_id} "
|
||||
f"prefix='{self.token_prefix}' status={status}>"
|
||||
)
|
||||
@@ -2500,6 +2500,79 @@ async def create_web_api_tokens_table() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def create_user_api_tokens_table() -> bool:
|
||||
table_exists = await check_table_exists("user_api_tokens")
|
||||
if table_exists:
|
||||
logger.info("ℹ️ Таблица user_api_tokens уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == "sqlite":
|
||||
create_sql = """
|
||||
CREATE TABLE user_api_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL UNIQUE,
|
||||
token_hash VARCHAR(128) NOT NULL UNIQUE,
|
||||
token_prefix VARCHAR(32) NOT NULL,
|
||||
token_last_digits VARCHAR(16) NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at DATETIME NULL,
|
||||
last_used_ip VARCHAR(64) NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT 1,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX idx_user_api_tokens_hash ON user_api_tokens(token_hash);
|
||||
CREATE INDEX idx_user_api_tokens_last_used ON user_api_tokens(last_used_at);
|
||||
"""
|
||||
elif db_type == "postgresql":
|
||||
create_sql = """
|
||||
CREATE TABLE user_api_tokens (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash VARCHAR(128) NOT NULL UNIQUE,
|
||||
token_prefix VARCHAR(32) NOT NULL,
|
||||
token_last_digits VARCHAR(16) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
last_used_at TIMESTAMP NULL,
|
||||
last_used_ip VARCHAR(64) NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE
|
||||
);
|
||||
CREATE INDEX idx_user_api_tokens_hash ON user_api_tokens(token_hash);
|
||||
CREATE INDEX idx_user_api_tokens_last_used ON user_api_tokens(last_used_at);
|
||||
"""
|
||||
else:
|
||||
create_sql = """
|
||||
CREATE TABLE user_api_tokens (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL UNIQUE,
|
||||
token_hash VARCHAR(128) NOT NULL UNIQUE,
|
||||
token_prefix VARCHAR(32) NOT NULL,
|
||||
token_last_digits VARCHAR(16) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
last_used_at TIMESTAMP NULL,
|
||||
last_used_ip VARCHAR(64) NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
CONSTRAINT fk_user_api_tokens_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB;
|
||||
CREATE INDEX idx_user_api_tokens_hash ON user_api_tokens(token_hash);
|
||||
CREATE INDEX idx_user_api_tokens_last_used ON user_api_tokens(last_used_at);
|
||||
"""
|
||||
|
||||
await conn.execute(text(create_sql))
|
||||
logger.info("✅ Таблица user_api_tokens создана")
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"❌ Ошибка создания таблицы user_api_tokens: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def create_privacy_policies_table() -> bool:
|
||||
table_exists = await check_table_exists("privacy_policies")
|
||||
if table_exists:
|
||||
@@ -2802,6 +2875,13 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей web_api_tokens")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ USER_API_TOKENS ===")
|
||||
user_api_tokens_ready = await create_user_api_tokens_table()
|
||||
if user_api_tokens_ready:
|
||||
logger.info("✅ Таблица user_api_tokens готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей user_api_tokens")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ PRIVACY_POLICIES ===")
|
||||
privacy_policies_ready = await create_privacy_policies_table()
|
||||
if privacy_policies_ready:
|
||||
|
||||
@@ -12,6 +12,7 @@ from app.keyboards.inline import (
|
||||
get_main_menu_keyboard,
|
||||
get_language_selection_keyboard,
|
||||
get_info_menu_keyboard,
|
||||
get_user_api_token_keyboard,
|
||||
)
|
||||
from app.localization.texts import get_texts, get_rules
|
||||
from app.database.models import User
|
||||
@@ -29,6 +30,7 @@ from app.utils.promo_offer import (
|
||||
from app.services.privacy_policy_service import PrivacyPolicyService
|
||||
from app.services.public_offer_service import PublicOfferService
|
||||
from app.services.faq_service import FaqService
|
||||
from app.services.user_api_token_service import user_api_token_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -132,6 +134,77 @@ async def show_info_menu(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def show_user_api_token(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
token = await user_api_token_service.get_token_for_user(db, db_user)
|
||||
is_active = bool(token and token.is_active)
|
||||
|
||||
if token and token.is_active:
|
||||
token_hint = f"{token.token_prefix}…{token.token_last_digits}"
|
||||
caption = texts.t(
|
||||
"USER_API_TOKEN_EXISTS",
|
||||
(
|
||||
"🔑 <b>Ваш API ключ</b>\n\n"
|
||||
"Сейчас активен ключ с префиксом <code>{token_hint}</code>.\n"
|
||||
"Чтобы получить полный ключ, выпустите новый — предыдущий станет недействительным."
|
||||
),
|
||||
).format(token_hint=token_hint)
|
||||
else:
|
||||
caption = texts.t(
|
||||
"USER_API_TOKEN_EMPTY",
|
||||
(
|
||||
"🔑 <b>API ключ не выпущен</b>\n\n"
|
||||
"Нажмите кнопку ниже, чтобы получить персональный ключ для внешней админки."
|
||||
),
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
caption,
|
||||
reply_markup=get_user_api_token_keyboard(
|
||||
db_user.language,
|
||||
has_active_token=is_active,
|
||||
),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def generate_user_api_token(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
new_token, _ = await user_api_token_service.generate_token(db, db_user)
|
||||
await db.commit()
|
||||
|
||||
message_text = texts.t(
|
||||
"USER_API_TOKEN_NEW",
|
||||
(
|
||||
"🎉 <b>Новый API ключ</b>\n\n"
|
||||
"<code>{token}</code>\n\n"
|
||||
"Сохраните ключ — повторно показать его невозможно. При необходимости вы можете выпустить новый."
|
||||
),
|
||||
).format(token=new_token)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=get_user_api_token_keyboard(
|
||||
db_user.language,
|
||||
has_active_token=True,
|
||||
),
|
||||
)
|
||||
await callback.answer(
|
||||
texts.t("USER_API_TOKEN_GENERATED_TOAST", "✅ Новый ключ создан"),
|
||||
show_alert=True,
|
||||
)
|
||||
|
||||
|
||||
async def show_faq_pages(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
@@ -782,6 +855,11 @@ def register_handlers(dp: Dispatcher):
|
||||
F.data == "menu_info",
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_user_api_token,
|
||||
F.data == "menu_api_token",
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_faq_pages,
|
||||
F.data == "menu_faq",
|
||||
@@ -822,3 +900,8 @@ def register_handlers(dp: Dispatcher):
|
||||
F.data.startswith("language_select:"),
|
||||
StateFilter(None)
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
generate_user_api_token,
|
||||
F.data == "user_api_token_generate",
|
||||
)
|
||||
|
||||
@@ -248,6 +248,13 @@ def get_main_menu_keyboard(
|
||||
InlineKeyboardButton(text=texts.MENU_SUPPORT, callback_data="menu_support")
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("MENU_API_TOKEN", "🔑 API ключ"),
|
||||
callback_data="menu_api_token",
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("MENU_INFO", "ℹ️ Инфо"),
|
||||
@@ -347,6 +354,30 @@ def get_info_menu_keyboard(
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
|
||||
def get_user_api_token_keyboard(
|
||||
language: str = DEFAULT_LANGUAGE,
|
||||
*,
|
||||
has_active_token: bool,
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
action_text = (
|
||||
texts.t("USER_API_TOKEN_REGENERATE", "🔄 Выпустить новый ключ")
|
||||
if has_active_token
|
||||
else texts.t("USER_API_TOKEN_GENERATE", "🔑 Выпустить API ключ")
|
||||
)
|
||||
|
||||
buttons: List[List[InlineKeyboardButton]] = [
|
||||
[InlineKeyboardButton(text=action_text, callback_data="user_api_token_generate")]
|
||||
]
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
|
||||
def get_happ_download_button_row(texts) -> Optional[List[InlineKeyboardButton]]:
|
||||
if not settings.is_happ_download_button_enabled():
|
||||
return None
|
||||
|
||||
@@ -310,6 +310,13 @@
|
||||
"MENU_RULES": "📋 Service rules",
|
||||
"MENU_SERVER_STATUS": "📊 Server status",
|
||||
"MENU_SUPPORT": "🛠️ Support",
|
||||
"MENU_API_TOKEN": "🔑 API key",
|
||||
"USER_API_TOKEN_EMPTY": "🔑 <b>API key not issued</b>\n\nPress the button below to generate your personal key for the external admin panel.",
|
||||
"USER_API_TOKEN_EXISTS": "🔑 <b>Your API key</b>\n\nAn active key with prefix <code>{token_hint}</code> is available.\nTo get the full key, issue a new one — the previous key will become invalid.",
|
||||
"USER_API_TOKEN_GENERATE": "🔑 Generate API key",
|
||||
"USER_API_TOKEN_REGENERATE": "🔄 Generate new key",
|
||||
"USER_API_TOKEN_NEW": "🎉 <b>New API key</b>\n\n<code>{token}</code>\n\nSave this key — it cannot be shown again. You can issue a new one if needed.",
|
||||
"USER_API_TOKEN_GENERATED_TOAST": "✅ New key created",
|
||||
"OPERATION_CANCELLED": "❌ Operation cancelled",
|
||||
"PERIOD_14_DAYS": "📅 14 days - {settings.format_price(settings.PRICE_14_DAYS)}",
|
||||
"PERIOD_30_DAYS": "📅 30 days - {settings.format_price(settings.PRICE_30_DAYS)}",
|
||||
|
||||
@@ -251,6 +251,13 @@
|
||||
"MENU_SERVER_STATUS": "📊 Статус серверов",
|
||||
"MENU_SUBSCRIPTION": "📱 Подписка",
|
||||
"MENU_SUPPORT": "🛠️ Техподдержка",
|
||||
"MENU_API_TOKEN": "🔑 API ключ",
|
||||
"USER_API_TOKEN_EMPTY": "🔑 <b>API ключ не выпущен</b>\n\nНажмите кнопку ниже, чтобы получить персональный ключ для внешней админки.",
|
||||
"USER_API_TOKEN_EXISTS": "🔑 <b>Ваш API ключ</b>\n\nСейчас активен ключ с префиксом <code>{token_hint}</code>.\nЧтобы получить полный ключ, выпустите новый — предыдущий станет недействительным.",
|
||||
"USER_API_TOKEN_GENERATE": "🔑 Выпустить API ключ",
|
||||
"USER_API_TOKEN_REGENERATE": "🔄 Выпустить новый ключ",
|
||||
"USER_API_TOKEN_NEW": "🎉 <b>Новый API ключ</b>\n\n<code>{token}</code>\n\nСохраните ключ — повторно показать его невозможно. При необходимости вы можете выпустить новый.",
|
||||
"USER_API_TOKEN_GENERATED_TOAST": "✅ Новый ключ создан",
|
||||
"MENU_TRIAL": "🧪 Тестовая подписка",
|
||||
"MY_BALANCE_BUTTON": "💰 Мой баланс",
|
||||
"MY_SUBSCRIPTION_BUTTON": "📱 Моя подписка",
|
||||
|
||||
@@ -727,7 +727,7 @@ class BackupService:
|
||||
"transactions", "welcome_texts", "subscriptions",
|
||||
"promocodes", "users", "promo_groups",
|
||||
"server_squads", "squads", "service_rules",
|
||||
"system_settings", "web_api_tokens", "monitoring_logs"
|
||||
"system_settings", "web_api_tokens", "user_api_tokens", "monitoring_logs"
|
||||
]
|
||||
|
||||
for table_name in tables_order:
|
||||
|
||||
102
app/services/user_api_token_service.py
Normal file
102
app/services/user_api_token_service.py
Normal file
@@ -0,0 +1,102 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud import user_api_token as crud
|
||||
from app.database.models import User, UserApiToken
|
||||
from app.utils.security import generate_api_token, hash_api_token
|
||||
|
||||
|
||||
class UserApiTokenService:
|
||||
"""Service for issuing and validating user-facing API tokens."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.algorithm = settings.USER_API_TOKEN_HASH_ALGORITHM or "sha256"
|
||||
|
||||
def hash_token(self, token: str) -> str:
|
||||
return hash_api_token(token, self.algorithm) # type: ignore[arg-type]
|
||||
|
||||
async def get_token_for_user(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
) -> Optional[UserApiToken]:
|
||||
if user.api_token:
|
||||
return user.api_token
|
||||
return await crud.get_token_by_user_id(db, user.id)
|
||||
|
||||
async def generate_token(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
) -> Tuple[str, UserApiToken]:
|
||||
plain_token = generate_api_token()
|
||||
token_hash = self.hash_token(plain_token)
|
||||
token_prefix = plain_token[:12]
|
||||
token_last_digits = plain_token[-6:]
|
||||
|
||||
existing = await crud.get_token_by_user_id(db, user.id)
|
||||
if existing:
|
||||
token = await crud.update_token(
|
||||
db,
|
||||
existing,
|
||||
token_hash=token_hash,
|
||||
token_prefix=token_prefix,
|
||||
token_last_digits=token_last_digits,
|
||||
)
|
||||
else:
|
||||
token = await crud.create_token(
|
||||
db,
|
||||
user_id=user.id,
|
||||
token_hash=token_hash,
|
||||
token_prefix=token_prefix,
|
||||
token_last_digits=token_last_digits,
|
||||
)
|
||||
|
||||
if user.api_token is None:
|
||||
user.api_token = token
|
||||
|
||||
return plain_token, token
|
||||
|
||||
async def deactivate_token(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
token: UserApiToken,
|
||||
) -> UserApiToken:
|
||||
return await crud.deactivate_token(db, token)
|
||||
|
||||
async def authenticate(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
token_value: str,
|
||||
*,
|
||||
remote_ip: Optional[str] = None,
|
||||
) -> Optional[UserApiToken]:
|
||||
normalized_value = (token_value or "").strip()
|
||||
if not normalized_value:
|
||||
return None
|
||||
|
||||
token_hash = self.hash_token(normalized_value)
|
||||
token = await crud.get_token_by_hash(db, token_hash)
|
||||
|
||||
if not token or not token.is_active:
|
||||
return None
|
||||
|
||||
if token.user is None:
|
||||
token.user = await db.get(User, token.user_id)
|
||||
|
||||
token.last_used_at = datetime.utcnow()
|
||||
if remote_ip:
|
||||
token.last_used_ip = remote_ip
|
||||
await db.flush()
|
||||
return token
|
||||
|
||||
|
||||
user_api_token_service = UserApiTokenService()
|
||||
|
||||
|
||||
__all__ = ["user_api_token_service", "UserApiTokenService"]
|
||||
@@ -24,6 +24,7 @@ from .routes import (
|
||||
tokens,
|
||||
transactions,
|
||||
users,
|
||||
user_api,
|
||||
)
|
||||
|
||||
|
||||
@@ -83,6 +84,10 @@ OPENAPI_TAGS = [
|
||||
"name": "pages",
|
||||
"description": "Управление контентом публичных страниц: оферта, политика, FAQ и правила.",
|
||||
},
|
||||
{
|
||||
"name": "user-api",
|
||||
"description": "Публичные эндпоинты для внешней админки, доступные по пользовательским API ключам.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -128,5 +133,6 @@ def create_web_api_app() -> FastAPI:
|
||||
app.include_router(tokens.router, prefix="/tokens", tags=["auth"])
|
||||
app.include_router(remnawave.router, prefix="/remnawave", tags=["remnawave"])
|
||||
app.include_router(miniapp.router, prefix="/miniapp", tags=["miniapp"])
|
||||
app.include_router(user_api.router, prefix="/user-api", tags=["user-api"])
|
||||
|
||||
return app
|
||||
|
||||
@@ -7,11 +7,13 @@ from fastapi.security import APIKeyHeader
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import WebApiToken
|
||||
from app.database.models import UserApiToken, WebApiToken
|
||||
from app.services.user_api_token_service import user_api_token_service
|
||||
from app.services.web_api_token_service import web_api_token_service
|
||||
|
||||
|
||||
api_key_header_scheme = APIKeyHeader(name="X-API-Key", auto_error=False)
|
||||
user_api_key_header_scheme = APIKeyHeader(name="X-User-API-Key", auto_error=False)
|
||||
|
||||
|
||||
async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
@@ -57,3 +59,40 @@ async def require_api_token(
|
||||
|
||||
await db.commit()
|
||||
return token
|
||||
|
||||
|
||||
async def require_user_api_token(
|
||||
request: Request,
|
||||
api_key_header: str | None = Security(user_api_key_header_scheme),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> UserApiToken:
|
||||
api_key = api_key_header
|
||||
|
||||
if not api_key:
|
||||
authorization = request.headers.get("Authorization")
|
||||
if authorization:
|
||||
scheme, _, credentials = authorization.partition(" ")
|
||||
if scheme.lower() == "bearer" and credentials:
|
||||
api_key = credentials
|
||||
|
||||
if not api_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Missing API key",
|
||||
)
|
||||
|
||||
token = await user_api_token_service.authenticate(
|
||||
db,
|
||||
api_key,
|
||||
remote_ip=request.client.host if request.client else None,
|
||||
)
|
||||
|
||||
if not token:
|
||||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired API key",
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return token
|
||||
|
||||
@@ -12,6 +12,7 @@ from . import (
|
||||
tokens,
|
||||
transactions,
|
||||
users,
|
||||
user_api,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -28,4 +29,5 @@ __all__ = [
|
||||
"tokens",
|
||||
"transactions",
|
||||
"users",
|
||||
"user_api",
|
||||
]
|
||||
|
||||
43
app/webapi/routes/user_api.py
Normal file
43
app/webapi/routes/user_api.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Security, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.database.models import UserApiToken
|
||||
|
||||
from ..dependencies import get_db_session, require_user_api_token
|
||||
from ..schemas.user_api import UserApiProfileResponse
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/profile", response_model=UserApiProfileResponse)
|
||||
async def get_profile(
|
||||
token: UserApiToken = Security(require_user_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> UserApiProfileResponse:
|
||||
user = token.user
|
||||
|
||||
if user is None:
|
||||
user = await get_user_by_id(db, token.user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
|
||||
|
||||
balance_rubles = round(user.balance_kopeks / 100, 2)
|
||||
|
||||
return UserApiProfileResponse(
|
||||
user_id=user.id,
|
||||
telegram_id=user.telegram_id,
|
||||
username=user.username,
|
||||
first_name=user.first_name,
|
||||
last_name=user.last_name,
|
||||
status=user.status,
|
||||
language=user.language,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
balance_rubles=balance_rubles,
|
||||
created_at=user.created_at,
|
||||
updated_at=user.updated_at,
|
||||
last_activity=user.last_activity,
|
||||
api_token_prefix=token.token_prefix,
|
||||
api_token_last_digits=token.token_last_digits,
|
||||
)
|
||||
28
app/webapi/schemas/user_api.py
Normal file
28
app/webapi/schemas/user_api.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class UserApiProfileResponse(BaseModel):
|
||||
user_id: int = Field(..., description="Internal database identifier of the user")
|
||||
telegram_id: int = Field(..., description="Telegram identifier of the user")
|
||||
username: Optional[str] = Field(None, description="Telegram @username if available")
|
||||
first_name: Optional[str] = Field(None, description="Telegram first name")
|
||||
last_name: Optional[str] = Field(None, description="Telegram last name")
|
||||
status: str = Field(..., description="Account status")
|
||||
language: str = Field(..., description="Preferred interface language")
|
||||
balance_kopeks: int = Field(..., description="Account balance in kopeks")
|
||||
balance_rubles: float = Field(..., description="Account balance in rubles")
|
||||
created_at: datetime = Field(..., description="Account creation timestamp")
|
||||
updated_at: datetime = Field(..., description="Last profile update timestamp")
|
||||
last_activity: Optional[datetime] = Field(None, description="Last bot interaction time")
|
||||
api_token_prefix: str = Field(..., description="First characters of the API token")
|
||||
api_token_last_digits: str = Field(..., description="Last characters of the API token")
|
||||
|
||||
model_config = {
|
||||
"from_attributes": True,
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["UserApiProfileResponse"]
|
||||
@@ -0,0 +1,53 @@
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.engine.reflection import Inspector
|
||||
|
||||
|
||||
revision: str = "9c1b5f0c4e7b"
|
||||
down_revision: Union[str, None] = "8fd1e338eb45"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
TABLE_NAME = "user_api_tokens"
|
||||
|
||||
|
||||
def _table_exists(inspector: Inspector) -> bool:
|
||||
return TABLE_NAME in inspector.get_table_names()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if _table_exists(inspector):
|
||||
return
|
||||
|
||||
op.create_table(
|
||||
TABLE_NAME,
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True),
|
||||
sa.Column("token_hash", sa.String(length=128), nullable=False, unique=True),
|
||||
sa.Column("token_prefix", sa.String(length=32), nullable=False),
|
||||
sa.Column("token_last_digits", sa.String(length=16), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||
sa.Column("last_used_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("last_used_ip", sa.String(length=64), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
)
|
||||
|
||||
op.create_index("ix_user_api_tokens_last_used_at", TABLE_NAME, ["last_used_at"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if not _table_exists(inspector):
|
||||
return
|
||||
|
||||
op.drop_index("ix_user_api_tokens_last_used_at", table_name=TABLE_NAME)
|
||||
op.drop_table(TABLE_NAME)
|
||||
Reference in New Issue
Block a user