aut flow
This commit is contained in:
134
app/api/auth.py
134
app/api/auth.py
@@ -225,6 +225,56 @@ async def telegram_binding_confirm(
|
||||
return TelegramBindingConfirmResponse(**result)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/telegram/store-token",
|
||||
response_model=dict,
|
||||
summary="Store JWT token for Telegram user (called after binding confirmation)",
|
||||
)
|
||||
async def telegram_store_token(
|
||||
chat_id: int,
|
||||
jwt_token: str,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Store JWT token in Redis after successful binding confirmation.
|
||||
|
||||
**Flow:**
|
||||
1. User confirms binding via /telegram/confirm
|
||||
2. Frontend receives jwt_token
|
||||
3. Frontend calls this endpoint to cache token in bot's Redis
|
||||
4. Bot can now use token for API calls
|
||||
|
||||
**Usage:**
|
||||
```
|
||||
POST /auth/telegram/store-token?chat_id=12345&jwt_token=eyJ...
|
||||
```
|
||||
"""
|
||||
|
||||
import redis
|
||||
|
||||
# Get Redis client from settings
|
||||
from app.core.config import settings
|
||||
redis_client = redis.from_url(settings.redis_url)
|
||||
|
||||
# Validate JWT token structure
|
||||
try:
|
||||
jwt_manager.verify_token(jwt_token)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid token: {e}")
|
||||
|
||||
# Store JWT in Redis with 30-day TTL
|
||||
cache_key = f"chat_id:{chat_id}:jwt"
|
||||
redis_client.setex(cache_key, 86400 * 30, jwt_token)
|
||||
|
||||
logger.info(f"JWT token stored for chat_id={chat_id}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Token stored successfully",
|
||||
"chat_id": chat_id,
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/telegram/authenticate",
|
||||
response_model=dict,
|
||||
@@ -235,11 +285,11 @@ async def telegram_authenticate(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get JWT token for Telegram user.
|
||||
Get JWT token for Telegram user (bot authentication).
|
||||
|
||||
**Usage in Bot:**
|
||||
```python
|
||||
# After user binding is confirmed
|
||||
# Get token for authenticated user
|
||||
response = api.post("/auth/telegram/authenticate?chat_id=12345")
|
||||
jwt_token = response["jwt_token"]
|
||||
```
|
||||
@@ -254,6 +304,86 @@ async def telegram_authenticate(
|
||||
return result
|
||||
|
||||
|
||||
@router.post(
|
||||
"/telegram/register",
|
||||
response_model=dict,
|
||||
summary="Create new user with Telegram binding",
|
||||
)
|
||||
async def telegram_register(
|
||||
chat_id: int,
|
||||
username: Optional[str] = None,
|
||||
first_name: Optional[str] = None,
|
||||
last_name: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Quick registration for new Telegram user.
|
||||
|
||||
**Flow:**
|
||||
1. Bot calls this endpoint on /start
|
||||
2. Creates new User with telegram_id
|
||||
3. Returns JWT for immediate API access
|
||||
4. User can update email/password later
|
||||
|
||||
**Usage in Bot:**
|
||||
```python
|
||||
result = api.post(
|
||||
"/auth/telegram/register",
|
||||
params={
|
||||
"chat_id": 12345,
|
||||
"username": "john_doe",
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
}
|
||||
)
|
||||
jwt_token = result["jwt_token"]
|
||||
```
|
||||
"""
|
||||
|
||||
from app.db.models.user import User
|
||||
|
||||
# Check if user already exists
|
||||
existing = db.query(User).filter_by(telegram_id=chat_id).first()
|
||||
if existing:
|
||||
service = AuthService(db)
|
||||
result = await service.authenticate_telegram_user(chat_id=chat_id)
|
||||
return {
|
||||
**result,
|
||||
"created": False,
|
||||
"message": "User already exists",
|
||||
}
|
||||
|
||||
# Create new user
|
||||
new_user = User(
|
||||
telegram_id=chat_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
try:
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Failed to create user: {e}")
|
||||
raise HTTPException(status_code=400, detail="Failed to create user")
|
||||
|
||||
# Create JWT
|
||||
service = AuthService(db)
|
||||
result = await service.authenticate_telegram_user(chat_id=chat_id)
|
||||
|
||||
if result:
|
||||
result["created"] = True
|
||||
result["message"] = f"User created successfully (user_id={new_user.id})"
|
||||
|
||||
logger.info(f"New Telegram user registered: chat_id={chat_id}, user_id={new_user.id}")
|
||||
|
||||
return result or {"success": False, "error": "Failed to create user"}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/logout",
|
||||
summary="Logout user",
|
||||
|
||||
@@ -8,11 +8,12 @@ from typing import Optional, Dict, Any
|
||||
from decimal import Decimal
|
||||
import aiohttp
|
||||
import time
|
||||
import asyncio
|
||||
import json
|
||||
import redis
|
||||
from aiogram import Bot, Dispatcher, types, F
|
||||
from aiogram.filters import Command
|
||||
from aiogram.types import Message
|
||||
import redis
|
||||
import json
|
||||
from app.security.hmac_manager import hmac_manager
|
||||
|
||||
|
||||
@@ -63,10 +64,16 @@ class TelegramBotClient:
|
||||
"""
|
||||
/start - Begin Telegram binding process.
|
||||
|
||||
Flow:
|
||||
1. Check if user already bound
|
||||
2. If not: Generate binding code
|
||||
3. Send link to user
|
||||
**Flow:**
|
||||
1. Check if user already bound (JWT in Redis)
|
||||
2. If not: Generate binding code via API
|
||||
3. Send binding link to user with code
|
||||
|
||||
**After binding:**
|
||||
- User clicks link and confirms
|
||||
- User's browser calls POST /api/v1/auth/telegram/confirm
|
||||
- Bot calls GET /api/v1/auth/telegram/authenticate?chat_id=XXXX
|
||||
- Bot stores JWT in Redis for future API calls
|
||||
"""
|
||||
chat_id = message.chat.id
|
||||
|
||||
@@ -75,70 +82,150 @@ class TelegramBotClient:
|
||||
existing_token = self.redis_client.get(jwt_key)
|
||||
|
||||
if existing_token:
|
||||
await message.answer("✅ You're already connected!\n\nUse /help for commands.")
|
||||
await message.answer(
|
||||
"✅ **You're already connected!**\n\n"
|
||||
"Use /balance to check wallets\n"
|
||||
"Use /add to add transactions\n"
|
||||
"Use /help for all commands",
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
return
|
||||
|
||||
# Generate binding code
|
||||
try:
|
||||
code = await self._api_call(
|
||||
logger.info(f"Starting binding for chat_id={chat_id}")
|
||||
|
||||
code_response = await self._api_call(
|
||||
method="POST",
|
||||
endpoint="/api/v1/auth/telegram/start",
|
||||
data={"chat_id": chat_id},
|
||||
use_jwt=False,
|
||||
)
|
||||
|
||||
binding_code = code.get("code")
|
||||
binding_code = code_response.get("code")
|
||||
if not binding_code:
|
||||
raise ValueError("No code in response")
|
||||
|
||||
# Store binding code in Redis for validation
|
||||
# (expires in 10 minutes as per backend TTL)
|
||||
binding_key = f"chat_id:{chat_id}:binding_code"
|
||||
self.redis_client.setex(
|
||||
binding_key,
|
||||
600,
|
||||
json.dumps({"code": binding_code, "created_at": datetime.utcnow().isoformat()})
|
||||
)
|
||||
|
||||
# Build binding link (replace with actual frontend URL)
|
||||
# Example: https://yourapp.com/auth/telegram/confirm?code=XXX&chat_id=123
|
||||
binding_url = (
|
||||
f"https://your-finance-app.com/auth/telegram/confirm"
|
||||
f"?code={binding_code}&chat_id={chat_id}"
|
||||
)
|
||||
|
||||
# Send binding link to user
|
||||
binding_url = f"https://your-app.com/auth/telegram?code={binding_code}&chat_id={chat_id}"
|
||||
|
||||
await message.answer(
|
||||
f"🔗 Click to bind your account:\n\n"
|
||||
f"[Open Account Binding]({binding_url})\n\n"
|
||||
f"Code expires in 10 minutes.",
|
||||
parse_mode="Markdown"
|
||||
f"🔗 **Click to bind your account:**\n\n"
|
||||
f"[📱 Open Account Binding]({binding_url})\n\n"
|
||||
f"⏱ Code expires in 10 minutes\n\n"
|
||||
f"❓ Already have an account? Just log in and click the link.",
|
||||
parse_mode="Markdown",
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
|
||||
|
||||
logger.info(f"Binding code sent to chat_id={chat_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Binding start error: {e}")
|
||||
await message.answer("❌ Binding failed. Try again later.")
|
||||
logger.error(f"Binding start error: {e}", exc_info=True)
|
||||
await message.answer("❌ Could not start binding. Try again later.")
|
||||
|
||||
# ========== Handler: /balance ==========
|
||||
async def cmd_balance(self, message: Message):
|
||||
"""
|
||||
/balance - Show wallet balances.
|
||||
|
||||
Requires:
|
||||
**Requires:**
|
||||
- User must be bound (JWT token in Redis)
|
||||
- JWT obtained via binding confirmation
|
||||
- API call with JWT auth
|
||||
|
||||
**Try:**
|
||||
Use /start to bind your account first
|
||||
"""
|
||||
chat_id = message.chat.id
|
||||
|
||||
# Get JWT token
|
||||
# Get JWT token from Redis
|
||||
jwt_token = self._get_user_jwt(chat_id)
|
||||
|
||||
if not jwt_token:
|
||||
await message.answer("❌ Not connected. Use /start to bind your account.")
|
||||
# Try to authenticate if user exists
|
||||
try:
|
||||
auth_result = await self._api_call(
|
||||
method="POST",
|
||||
endpoint="/api/v1/auth/telegram/authenticate",
|
||||
params={"chat_id": chat_id},
|
||||
use_jwt=False,
|
||||
)
|
||||
|
||||
if auth_result and auth_result.get("jwt_token"):
|
||||
jwt_token = auth_result["jwt_token"]
|
||||
|
||||
# Store in Redis for future use
|
||||
self.redis_client.setex(
|
||||
f"chat_id:{chat_id}:jwt",
|
||||
86400 * 30, # 30 days
|
||||
jwt_token
|
||||
)
|
||||
|
||||
logger.info(f"JWT obtained for chat_id={chat_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not authenticate user: {e}")
|
||||
|
||||
if not jwt_token:
|
||||
await message.answer(
|
||||
"❌ Not connected yet\n\n"
|
||||
"Use /start to bind your Telegram account first",
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
# Call API: GET /api/v1/wallets/summary?family_id=1
|
||||
wallets = await self._api_call(
|
||||
# Call API: GET /api/v1/accounts?family_id=1
|
||||
accounts_response = await self._api_call(
|
||||
method="GET",
|
||||
endpoint="/api/v1/wallets/summary",
|
||||
endpoint="/api/v1/accounts",
|
||||
jwt_token=jwt_token,
|
||||
params={"family_id": 1}, # TODO: Get from context
|
||||
params={"family_id": 1}, # TODO: Get from user context
|
||||
use_jwt=True,
|
||||
)
|
||||
|
||||
accounts = accounts_response if isinstance(accounts_response, list) else accounts_response.get("accounts", [])
|
||||
|
||||
if not accounts:
|
||||
await message.answer(
|
||||
"💰 No accounts found\n\n"
|
||||
"Contact support to set up your first account",
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
return
|
||||
|
||||
# Format response
|
||||
response = "💰 **Your Wallets:**\n\n"
|
||||
for wallet in wallets:
|
||||
response += f"📊 {wallet['name']}: ${wallet['balance']}\n"
|
||||
response = "💰 **Your Accounts:**\n\n"
|
||||
for account in accounts[:10]: # Limit to 10
|
||||
balance = account.get("balance", 0)
|
||||
currency = account.get("currency", "USD")
|
||||
response += f"📊 {account.get('name', 'Account')}: {balance} {currency}\n"
|
||||
|
||||
await message.answer(response, parse_mode="Markdown")
|
||||
logger.info(f"Balance shown for chat_id={chat_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Balance fetch error: {e}")
|
||||
await message.answer("❌ Could not fetch balance. Try again later.")
|
||||
logger.error(f"Balance fetch error for {chat_id}: {e}", exc_info=True)
|
||||
await message.answer(
|
||||
"❌ Could not fetch balance\n\n"
|
||||
"Try again later or contact support",
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
|
||||
# ========== Handler: /add (Create Transaction) ==========
|
||||
async def cmd_add_transaction(self, message: Message):
|
||||
@@ -230,16 +317,51 @@ class TelegramBotClient:
|
||||
|
||||
# ========== Handler: /help ==========
|
||||
async def cmd_help(self, message: Message):
|
||||
"""Show available commands"""
|
||||
help_text = """
|
||||
🤖 **Finance Bot Commands:**
|
||||
"""Show available commands and binding instructions"""
|
||||
chat_id = message.chat.id
|
||||
jwt_key = f"chat_id:{chat_id}:jwt"
|
||||
is_bound = self.redis_client.get(jwt_key) is not None
|
||||
|
||||
if is_bound:
|
||||
help_text = """🤖 **Finance Bot - Commands:**
|
||||
|
||||
/start - Bind your Telegram account
|
||||
/balance - Show wallet balances
|
||||
🔗 **Account**
|
||||
/start - Re-bind account (if needed)
|
||||
/balance - Show all account balances
|
||||
/settings - Account settings
|
||||
|
||||
💰 **Transactions**
|
||||
/add - Add new transaction
|
||||
/reports - View reports (daily/weekly/monthly)
|
||||
/recent - Last 10 transactions
|
||||
/category - View by category
|
||||
|
||||
📊 **Reports**
|
||||
/daily - Daily spending report
|
||||
/weekly - Weekly summary
|
||||
/monthly - Monthly summary
|
||||
|
||||
❓ **Help**
|
||||
/help - This message
|
||||
"""
|
||||
else:
|
||||
help_text = """🤖 **Finance Bot - Getting Started**
|
||||
|
||||
**Step 1: Bind Your Account**
|
||||
/start - Click the link to bind your Telegram account
|
||||
|
||||
**Step 2: Login**
|
||||
Use your email and password on the binding page
|
||||
|
||||
**Step 3: Done!**
|
||||
- /balance - View your accounts
|
||||
- /add - Create transactions
|
||||
- /help - See all commands
|
||||
|
||||
🔒 **Privacy**
|
||||
Your data is encrypted and secure
|
||||
Only you can access your accounts
|
||||
"""
|
||||
|
||||
await message.answer(help_text, parse_mode="Markdown")
|
||||
|
||||
# ========== API Communication Methods ==========
|
||||
@@ -253,29 +375,40 @@ class TelegramBotClient:
|
||||
use_jwt: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Make HTTP request to API with proper auth headers.
|
||||
Make HTTP request to API with proper authentication headers.
|
||||
|
||||
Headers:
|
||||
- Authorization: Bearer <jwt_token>
|
||||
**Headers:**
|
||||
- Authorization: Bearer <jwt_token> (if use_jwt=True)
|
||||
- X-Client-Id: telegram_bot
|
||||
- X-Signature: HMAC_SHA256(...)
|
||||
- X-Signature: HMAC_SHA256(method + endpoint + timestamp + body)
|
||||
- X-Timestamp: unix timestamp
|
||||
|
||||
**Auth Flow:**
|
||||
1. For public endpoints (binding): use_jwt=False, no Authorization header
|
||||
2. For user endpoints: use_jwt=True, pass jwt_token
|
||||
3. All calls include HMAC signature for integrity
|
||||
|
||||
**Raises:**
|
||||
- Exception: API error with status code and message
|
||||
"""
|
||||
|
||||
if not self.session:
|
||||
raise RuntimeError("Session not initialized")
|
||||
|
||||
# Build URL
|
||||
url = f"{self.api_base_url}{endpoint}"
|
||||
|
||||
# Build headers
|
||||
headers = {
|
||||
"X-Client-Id": "telegram_bot",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
# Add JWT if provided
|
||||
# Add JWT if provided and requested
|
||||
if use_jwt and jwt_token:
|
||||
headers["Authorization"] = f"Bearer {jwt_token}"
|
||||
|
||||
# Add HMAC signature
|
||||
# Add HMAC signature for integrity verification
|
||||
timestamp = int(time.time())
|
||||
headers["X-Timestamp"] = str(timestamp)
|
||||
|
||||
@@ -288,20 +421,39 @@ class TelegramBotClient:
|
||||
headers["X-Signature"] = signature
|
||||
|
||||
# Make request
|
||||
url = f"{self.api_base_url}{endpoint}"
|
||||
|
||||
async with self.session.request(
|
||||
method=method,
|
||||
url=url,
|
||||
json=data,
|
||||
params=params,
|
||||
headers=headers,
|
||||
) as response:
|
||||
if response.status >= 400:
|
||||
error_text = await response.text()
|
||||
raise Exception(f"API error {response.status}: {error_text}")
|
||||
|
||||
return await response.json()
|
||||
try:
|
||||
async with self.session.request(
|
||||
method=method,
|
||||
url=url,
|
||||
json=data,
|
||||
params=params,
|
||||
headers=headers,
|
||||
timeout=aiohttp.ClientTimeout(total=10),
|
||||
) as response:
|
||||
response_text = await response.text()
|
||||
|
||||
if response.status >= 400:
|
||||
logger.error(
|
||||
f"API error {response.status}: {endpoint}\n"
|
||||
f"Response: {response_text[:500]}"
|
||||
)
|
||||
raise Exception(
|
||||
f"API error {response.status}: {response_text[:200]}"
|
||||
)
|
||||
|
||||
# Parse JSON response
|
||||
try:
|
||||
return json.loads(response_text)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(f"Invalid JSON response from {endpoint}: {response_text}")
|
||||
return {"data": response_text}
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.error(f"API timeout: {endpoint}")
|
||||
raise Exception("Request timeout")
|
||||
except Exception as e:
|
||||
logger.error(f"API call failed ({method} {endpoint}): {e}")
|
||||
raise
|
||||
|
||||
def _get_user_jwt(self, chat_id: int) -> Optional[str]:
|
||||
"""Get JWT token for chat_id from Redis"""
|
||||
|
||||
@@ -86,7 +86,15 @@ class HMACVerificationMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Callable) -> Any:
|
||||
# Skip verification for public endpoints
|
||||
public_paths = ["/health", "/docs", "/openapi.json", "/api/v1/auth/login", "/api/v1/auth/telegram/start"]
|
||||
public_paths = [
|
||||
"/health",
|
||||
"/docs",
|
||||
"/openapi.json",
|
||||
"/api/v1/auth/login",
|
||||
"/api/v1/auth/telegram/start",
|
||||
"/api/v1/auth/telegram/register",
|
||||
"/api/v1/auth/telegram/authenticate",
|
||||
]
|
||||
if request.url.path in public_paths:
|
||||
return await call_next(request)
|
||||
|
||||
@@ -153,7 +161,15 @@ class JWTAuthenticationMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Callable) -> Any:
|
||||
# Skip auth for public endpoints
|
||||
public_paths = ["/health", "/docs", "/openapi.json", "/api/v1/auth/login", "/api/v1/auth/telegram/start"]
|
||||
public_paths = [
|
||||
"/health",
|
||||
"/docs",
|
||||
"/openapi.json",
|
||||
"/api/v1/auth/login",
|
||||
"/api/v1/auth/telegram/start",
|
||||
"/api/v1/auth/telegram/register",
|
||||
"/api/v1/auth/telegram/authenticate",
|
||||
]
|
||||
if request.url.path in public_paths:
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
"""
|
||||
Authentication Service - User login, token management
|
||||
Authentication Service - User login, token management, Telegram binding
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any
|
||||
import secrets
|
||||
import json
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db.models import User
|
||||
from app.db.models.user import User
|
||||
from app.security.jwt_manager import jwt_manager
|
||||
from app.core.config import settings
|
||||
import logging
|
||||
import redis
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Redis connection for caching binding codes
|
||||
redis_client = redis.from_url(settings.redis_url)
|
||||
|
||||
|
||||
class AuthService:
|
||||
"""Handles user authentication and token management"""
|
||||
"""Handles user authentication, token management, and Telegram binding"""
|
||||
|
||||
TELEGRAM_BINDING_CODE_TTL = 600 # 10 minutes
|
||||
BINDING_CODE_LENGTH = 24
|
||||
@@ -23,41 +29,251 @@ class AuthService:
|
||||
self.db = db
|
||||
|
||||
async def create_telegram_binding_code(self, chat_id: int) -> str:
|
||||
"""Generate temporary code for Telegram user binding"""
|
||||
"""
|
||||
Generate temporary code for Telegram user binding.
|
||||
|
||||
**Flow:**
|
||||
1. Bot calls /auth/telegram/start with chat_id
|
||||
2. Service generates random code and stores in Redis
|
||||
3. Bot builds link: https://bot.example.com/bind?code=XXX
|
||||
4. Bot sends link to user in Telegram
|
||||
5. User clicks link, authenticates, calls /auth/telegram/confirm
|
||||
"""
|
||||
code = secrets.token_urlsafe(self.BINDING_CODE_LENGTH)
|
||||
|
||||
# Store in Redis with TTL (10 minutes)
|
||||
cache_key = f"binding_code:{code}"
|
||||
cache_data = {
|
||||
"chat_id": chat_id,
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
redis_client.setex(
|
||||
cache_key,
|
||||
self.TELEGRAM_BINDING_CODE_TTL,
|
||||
json.dumps(cache_data),
|
||||
)
|
||||
|
||||
logger.info(f"Generated Telegram binding code for chat_id={chat_id}")
|
||||
return code
|
||||
|
||||
async def login(self, email: str, password: str) -> Dict[str, Any]:
|
||||
"""Authenticate user with email/password"""
|
||||
async def confirm_telegram_binding(
|
||||
self,
|
||||
user_id: int,
|
||||
chat_id: int,
|
||||
code: str,
|
||||
username: Optional[str] = None,
|
||||
first_name: Optional[str] = None,
|
||||
last_name: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Confirm Telegram binding after user clicks link.
|
||||
|
||||
user = self.db.query(User).filter_by(email=email).first()
|
||||
**Flow:**
|
||||
1. User authenticates with email/password
|
||||
2. User clicks binding link: /bind?code=XXX
|
||||
3. Frontend calls /auth/telegram/confirm with code
|
||||
4. Service validates code from Redis
|
||||
5. Service links user.id with telegram_id
|
||||
6. Service returns JWT for bot to use
|
||||
|
||||
**Returns:**
|
||||
{
|
||||
"success": True,
|
||||
"user_id": 123,
|
||||
"jwt_token": "eyJ...",
|
||||
"expires_at": "2025-12-11T12:00:00",
|
||||
}
|
||||
|
||||
**Errors:**
|
||||
- Code expired or not found
|
||||
- Code chat_id mismatch
|
||||
- User not found
|
||||
- Binding already exists (user has different telegram_id)
|
||||
"""
|
||||
|
||||
# Validate code from Redis
|
||||
cache_key = f"binding_code:{code}"
|
||||
cached_data = redis_client.get(cache_key)
|
||||
|
||||
if not cached_data:
|
||||
logger.warning(f"Binding code not found or expired: {code}")
|
||||
return {"success": False, "error": "Code expired or invalid"}
|
||||
|
||||
binding_data = json.loads(cached_data)
|
||||
cached_chat_id = binding_data.get("chat_id")
|
||||
|
||||
if cached_chat_id != chat_id:
|
||||
logger.warning(
|
||||
f"Chat ID mismatch: expected {cached_chat_id}, got {chat_id}"
|
||||
)
|
||||
return {"success": False, "error": "Code mismatch"}
|
||||
|
||||
# Get user from database
|
||||
user = self.db.query(User).filter_by(id=user_id).first()
|
||||
if not user:
|
||||
logger.error(f"User not found: {user_id}")
|
||||
return {"success": False, "error": "User not found"}
|
||||
|
||||
# Check if binding already exists (user already bound to different telegram)
|
||||
if user.telegram_id and user.telegram_id != chat_id:
|
||||
logger.warning(
|
||||
f"User {user_id} already bound to telegram_id={user.telegram_id}, "
|
||||
f"attempting to bind to {chat_id}"
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Account already bound to different Telegram account (chat_id={user.telegram_id})"
|
||||
}
|
||||
|
||||
# Update user with Telegram info
|
||||
user.telegram_id = chat_id
|
||||
if username:
|
||||
user.username = username
|
||||
if first_name:
|
||||
user.first_name = first_name
|
||||
if last_name:
|
||||
user.last_name = last_name
|
||||
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
try:
|
||||
self.db.commit()
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Database error during binding confirmation: {e}")
|
||||
return {"success": False, "error": "Database error"}
|
||||
|
||||
# Create JWT token for bot
|
||||
access_token = jwt_manager.create_access_token(user_id=user.id)
|
||||
|
||||
# Remove code from Redis
|
||||
redis_client.delete(cache_key)
|
||||
|
||||
logger.info(
|
||||
f"Telegram binding confirmed: user_id={user_id}, chat_id={chat_id}, "
|
||||
f"username={username}"
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"user_id": user.id,
|
||||
"jwt_token": access_token,
|
||||
"expires_at": (
|
||||
datetime.utcnow() + timedelta(hours=24)
|
||||
).isoformat(),
|
||||
}
|
||||
|
||||
async def create_session(
|
||||
self,
|
||||
user_id: int,
|
||||
device_id: Optional[str] = None,
|
||||
) -> tuple[str, str]:
|
||||
"""
|
||||
Create session and issue tokens.
|
||||
|
||||
**Returns:**
|
||||
(access_token, refresh_token)
|
||||
"""
|
||||
|
||||
user = self.db.query(User).filter_by(id=user_id).first()
|
||||
if not user:
|
||||
raise ValueError("User not found")
|
||||
|
||||
# In production: verify password with bcrypt
|
||||
# For MVP: simple comparison (change this!)
|
||||
# Create tokens
|
||||
access_token = jwt_manager.create_access_token(user_id=user.id)
|
||||
refresh_token = jwt_manager.create_refresh_token(user_id=user.id)
|
||||
|
||||
# Store refresh token in Redis for validation
|
||||
token_key = f"refresh_token:{refresh_token}"
|
||||
token_data = {
|
||||
"user_id": user.id,
|
||||
"device_id": device_id,
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
# Refresh token valid for 30 days
|
||||
redis_client.setex(
|
||||
token_key,
|
||||
86400 * 30,
|
||||
json.dumps(token_data),
|
||||
)
|
||||
|
||||
# Update user activity
|
||||
user.last_activity = datetime.utcnow()
|
||||
self.db.commit()
|
||||
|
||||
logger.info(f"Session created for user_id={user_id}")
|
||||
|
||||
return access_token, refresh_token
|
||||
|
||||
async def refresh_access_token(
|
||||
self,
|
||||
refresh_token: str,
|
||||
user_id: int,
|
||||
) -> str:
|
||||
"""
|
||||
Issue new access token using valid refresh token.
|
||||
|
||||
**Flow:**
|
||||
1. Check refresh token in Redis
|
||||
2. Validate it belongs to user_id
|
||||
3. Create new access token
|
||||
4. Return new token (don't invalidate refresh token)
|
||||
"""
|
||||
|
||||
token_key = f"refresh_token:{refresh_token}"
|
||||
token_data = redis_client.get(token_key)
|
||||
|
||||
if not token_data:
|
||||
logger.warning(f"Refresh token not found: {user_id}")
|
||||
raise ValueError("Invalid refresh token")
|
||||
|
||||
data = json.loads(token_data)
|
||||
if data.get("user_id") != user_id:
|
||||
logger.warning(f"Refresh token user mismatch: {user_id}")
|
||||
raise ValueError("Token user mismatch")
|
||||
|
||||
# Create new access token
|
||||
access_token = jwt_manager.create_access_token(user_id=user_id)
|
||||
|
||||
logger.info(f"Access token refreshed for user_id={user_id}")
|
||||
|
||||
return access_token
|
||||
|
||||
async def authenticate_telegram_user(self, chat_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get JWT token for Telegram user (bot authentication).
|
||||
|
||||
**Flow:**
|
||||
1. Bot queries: GET /auth/telegram/authenticate?chat_id=12345
|
||||
2. Service finds user by telegram_id
|
||||
3. Service creates/returns JWT
|
||||
4. Bot stores JWT for API calls
|
||||
|
||||
**Returns:**
|
||||
{
|
||||
"user_id": 123,
|
||||
"jwt_token": "eyJ...",
|
||||
"expires_at": "2025-12-11T12:00:00",
|
||||
} or None if not found
|
||||
"""
|
||||
|
||||
user = self.db.query(User).filter_by(telegram_id=chat_id).first()
|
||||
|
||||
if not user:
|
||||
logger.warning(f"Telegram user not found: {chat_id}")
|
||||
return None
|
||||
|
||||
# Create JWT token
|
||||
access_token = jwt_manager.create_access_token(user_id=user.id)
|
||||
|
||||
logger.info(f"User {user.id} logged in")
|
||||
logger.info(f"Telegram user authenticated: chat_id={chat_id}, user_id={user.id}")
|
||||
|
||||
return {
|
||||
"user_id": user.id,
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer",
|
||||
"jwt_token": access_token,
|
||||
"expires_at": (
|
||||
datetime.utcnow() + timedelta(hours=24)
|
||||
).isoformat(),
|
||||
}
|
||||
|
||||
async def refresh_token(self, refresh_token: str) -> Dict[str, Any]:
|
||||
"""Refresh access token"""
|
||||
|
||||
try:
|
||||
payload = jwt_manager.verify_token(refresh_token)
|
||||
new_token = jwt_manager.create_access_token(user_id=payload.user_id)
|
||||
return {
|
||||
"access_token": new_token,
|
||||
"token_type": "bearer",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Token refresh failed: {e}")
|
||||
raise ValueError("Invalid refresh token")
|
||||
|
||||
Reference in New Issue
Block a user