diff --git a/app/api/users.py b/app/api/users.py index a781f2d..0667721 100644 --- a/app/api/users.py +++ b/app/api/users.py @@ -1,6 +1,9 @@ +import os +import secrets from fastapi import APIRouter, HTTPException, Body, Response from fastapi.params import Query -from app.models.user import UserCreate, UserLogin, VerifyCode +from app import db +from app.models.user import QrApprove, UserCreate, UserLogin, VerifyCode from app.models.request import ValidateRequest from app.services.auth import AuthService from app.db.database import users_collection, sessions_collection @@ -16,6 +19,8 @@ from app.services.dailyquests import DailyQuestsService coins_service = CoinsService() +qr_logins_collection = db["qr_logins"] + router = APIRouter( tags=["Users"] ) @@ -148,6 +153,33 @@ async def get_me( """ return await AuthService().get_current_user(accessToken, clientToken) +@router.post("/auth/qr/init") +async def qr_init(device_id: str | None = Query(default=None)): + token = secrets.token_urlsafe(24) + expires_at = datetime.utcnow() + timedelta(minutes=2) + + await qr_logins_collection.insert_one({ + "token": token, + "device_id": device_id, + "status": "pending", + "approved_username": None, + "created_at": datetime.utcnow(), + "expires_at": expires_at, + }) + + # deep-link в бота + BOT_USERNAME = os.getenv("TELEGRAM_BOT_USERNAME") + qr_url = f"https://t.me/{BOT_USERNAME}?start=qr_{token}" + return {"token": token, "qr_url": qr_url, "expires_at": expires_at.isoformat()} + +@router.post("/auth/qr/approve") +async def qr_approve(payload: QrApprove): + return await AuthService().approve_qr_login(payload.token, payload.telegram_user_id) + +@router.get("/auth/qr/status") +async def qr_status(token: str = Query(...), device_id: str | None = Query(default=None)): + return await AuthService().qr_status(token, device_id) + ### daily reward @router.post("/users/daily/claim") diff --git a/app/models/user.py b/app/models/user.py index e693537..cf76778 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -38,4 +38,8 @@ class VerifyCode(BaseModel): username: str code: str telegram_user_id: int - telegram_username: Optional[str] = None \ No newline at end of file + telegram_username: Optional[str] = None + +class QrApprove(BaseModel): + token: str + telegram_user_id: int \ No newline at end of file diff --git a/app/services/auth.py b/app/services/auth.py index 8694511..3724d26 100644 --- a/app/services/auth.py +++ b/app/services/auth.py @@ -2,6 +2,7 @@ import base64 import json from fastapi import HTTPException, UploadFile from fastapi.responses import JSONResponse +from app import db from app.models.user import UserLogin, UserInDB, UserCreate, Session from app.utils.misc import ( verify_password, @@ -23,6 +24,8 @@ env_path = Path(__file__).parent.parent / ".env" load_dotenv(dotenv_path=env_path) FILES_URL = os.getenv("FILES_URL") +qr_logins_collection = db["qr_logins"] + class AuthService: async def register(self, user: UserCreate): # Проверяем, существует ли пользователь @@ -380,3 +383,75 @@ class AuthService: "value": base64_textures }] } + + async def approve_qr_login(self, token: str, telegram_user_id: int): + qr = await qr_logins_collection.find_one({"token": token}) + if not qr: + raise HTTPException(404, "QR token not found") + + if qr["status"] != "pending": + raise HTTPException(400, "QR token already used or not pending") + + if datetime.utcnow() > qr["expires_at"]: + await qr_logins_collection.update_one({"token": token}, {"$set": {"status": "expired"}}) + raise HTTPException(400, "QR token expired") + + # находим пользователя по telegram_user_id + user = await users_collection.find_one({"telegram_user_id": telegram_user_id}) + if not user: + raise HTTPException(403, "Telegram аккаунт не привязан") + + if not user.get("is_verified"): + raise HTTPException(403, "Пользователь не верифицирован") + + await qr_logins_collection.update_one( + {"token": token}, + {"$set": {"status": "approved", "approved_username": user["username"]}} + ) + return {"status": "success"} + + async def qr_status(self, token: str, device_id: str | None = None): + qr = await qr_logins_collection.find_one({"token": token}) + if not qr: + raise HTTPException(404, "QR token not found") + + if datetime.utcnow() > qr["expires_at"] and qr["status"] == "pending": + await qr_logins_collection.update_one({"token": token}, {"$set": {"status": "expired"}}) + return {"status": "expired"} + + # если хотите привязку к устройству: + if device_id and qr.get("device_id") and qr["device_id"] != device_id: + raise HTTPException(403, "Device mismatch") + + if qr["status"] == "approved": + username = qr["approved_username"] + user = await users_collection.find_one({"username": username}) + if not user: + raise HTTPException(404, "User not found") + + # генерим токены как в login() + access_token = create_access_token({"sub": user["username"], "uuid": user["uuid"]}) + client_token = str(uuid.uuid4()) + + session = Session( + access_token=access_token, + client_token=client_token, + user_uuid=user["uuid"], + expires_at=datetime.utcnow() + timedelta(minutes=1440), + ) + await sessions_collection.insert_one(session.dict()) + + # одноразовость + await qr_logins_collection.update_one( + {"token": token}, + {"$set": {"status": "consumed"}} + ) + + return { + "status": "ok", + "accessToken": access_token, + "clientToken": client_token, + "selectedProfile": {"id": user["uuid"], "name": user["username"]}, + } + + return {"status": qr["status"]} diff --git a/app/webhooks/telegram.py b/app/webhooks/telegram.py index f24e3c6..c56a5c1 100644 --- a/app/webhooks/telegram.py +++ b/app/webhooks/telegram.py @@ -29,6 +29,17 @@ class Register(StatesGroup): @dp.message(CommandStart()) async def start(message: Message, state: FSMContext, command: CommandObject): + if command.args and command.args.startswith("qr_"): + token = command.args.removeprefix("qr_").strip() + tg_user = message.from_user + try: + await auth_service.approve_qr_login(token=token, telegram_user_id=tg_user.id) + await message.answer("✅ Вход подтверждён. Вернитесь в лаунчер.") + except Exception as e: + await message.answer(f"❌ Не удалось подтвердить вход: {e}") + return + + # старое поведение регистрации/верификации: if command.args: await state.update_data(username=command.args) await state.set_state(Register.code)