aut flow
This commit is contained in:
333
.history/app/api/auth_20251210221252.py
Normal file
333
.history/app/api/auth_20251210221252.py
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
"""
|
||||||
|
Authentication API Endpoints - Login, Token Management, Telegram Binding
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
|
from typing import Optional
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.db.database import get_db
|
||||||
|
from app.services.auth_service import AuthService
|
||||||
|
from app.security.jwt_manager import jwt_manager
|
||||||
|
import logging
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/v1/auth", tags=["authentication"])
|
||||||
|
|
||||||
|
|
||||||
|
# Request/Response Models
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class LoginResponse(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
refresh_token: str
|
||||||
|
user_id: int
|
||||||
|
expires_in: int # seconds
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramBindingStartRequest(BaseModel):
|
||||||
|
chat_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramBindingStartResponse(BaseModel):
|
||||||
|
code: str
|
||||||
|
expires_in: int # seconds
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramBindingConfirmRequest(BaseModel):
|
||||||
|
code: str
|
||||||
|
chat_id: int
|
||||||
|
username: Optional[str] = None
|
||||||
|
first_name: Optional[str] = None
|
||||||
|
last_name: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramBindingConfirmResponse(BaseModel):
|
||||||
|
success: bool
|
||||||
|
user_id: int
|
||||||
|
jwt_token: str
|
||||||
|
expires_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class TokenRefreshRequest(BaseModel):
|
||||||
|
refresh_token: str
|
||||||
|
|
||||||
|
|
||||||
|
class TokenRefreshResponse(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
expires_in: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/login",
|
||||||
|
response_model=LoginResponse,
|
||||||
|
summary="User login with email & password",
|
||||||
|
)
|
||||||
|
async def login(
|
||||||
|
request: LoginRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> LoginResponse:
|
||||||
|
"""
|
||||||
|
Authenticate user and create session.
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
- access_token: Short-lived JWT (15 min)
|
||||||
|
- refresh_token: Long-lived refresh token (30 days)
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```
|
||||||
|
Authorization: Bearer <access_token>
|
||||||
|
X-Device-Id: device_uuid # For tracking
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
# TODO: Verify email + password
|
||||||
|
# For MVP: Assume credentials are valid
|
||||||
|
|
||||||
|
from app.db.models import User
|
||||||
|
|
||||||
|
user = db.query(User).filter(User.email == request.email).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||||
|
|
||||||
|
service = AuthService(db)
|
||||||
|
access_token, refresh_token = await service.create_session(
|
||||||
|
user_id=user.id,
|
||||||
|
device_id=request.__dict__.get("device_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
return LoginResponse(
|
||||||
|
access_token=access_token,
|
||||||
|
refresh_token=refresh_token,
|
||||||
|
user_id=user.id,
|
||||||
|
expires_in=15 * 60, # 15 minutes
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/refresh",
|
||||||
|
response_model=TokenRefreshResponse,
|
||||||
|
summary="Refresh access token",
|
||||||
|
)
|
||||||
|
async def refresh_token(
|
||||||
|
request: TokenRefreshRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> TokenRefreshResponse:
|
||||||
|
"""
|
||||||
|
Issue new access token using refresh token.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
1. Access token expires
|
||||||
|
2. Send refresh_token to this endpoint
|
||||||
|
3. Receive new access_token (without creating new session)
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
token_payload = jwt_manager.verify_token(request.refresh_token)
|
||||||
|
if token_payload.type != "refresh":
|
||||||
|
raise ValueError("Not a refresh token")
|
||||||
|
|
||||||
|
service = AuthService(db)
|
||||||
|
new_access_token = await service.refresh_access_token(
|
||||||
|
refresh_token=request.refresh_token,
|
||||||
|
user_id=token_payload.sub,
|
||||||
|
)
|
||||||
|
|
||||||
|
return TokenRefreshResponse(
|
||||||
|
access_token=new_access_token,
|
||||||
|
expires_in=15 * 60,
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=401, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/telegram/start",
|
||||||
|
response_model=TelegramBindingStartResponse,
|
||||||
|
summary="Start Telegram binding flow",
|
||||||
|
)
|
||||||
|
async def telegram_binding_start(
|
||||||
|
request: TelegramBindingStartRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Generate binding code for Telegram user.
|
||||||
|
|
||||||
|
**Bot Flow:**
|
||||||
|
1. User sends /start
|
||||||
|
2. Bot calls this endpoint: POST /auth/telegram/start
|
||||||
|
3. Bot receives code and generates link
|
||||||
|
4. Bot sends message with link to user
|
||||||
|
5. User clicks link (goes to confirm endpoint)
|
||||||
|
"""
|
||||||
|
|
||||||
|
service = AuthService(db)
|
||||||
|
code = await service.create_telegram_binding_code(chat_id=request.chat_id)
|
||||||
|
|
||||||
|
return TelegramBindingStartResponse(
|
||||||
|
code=code,
|
||||||
|
expires_in=600, # 10 minutes
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/telegram/confirm",
|
||||||
|
response_model=TelegramBindingConfirmResponse,
|
||||||
|
summary="Confirm Telegram binding",
|
||||||
|
)
|
||||||
|
async def telegram_binding_confirm(
|
||||||
|
request: TelegramBindingConfirmRequest,
|
||||||
|
current_request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Confirm Telegram binding and issue JWT.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
1. User logs in or creates account
|
||||||
|
2. User clicks binding link with code
|
||||||
|
3. Frontend calls this endpoint with code + user context
|
||||||
|
4. Backend creates TelegramIdentity record
|
||||||
|
5. Backend returns JWT for bot to use
|
||||||
|
|
||||||
|
**Bot Usage:**
|
||||||
|
```python
|
||||||
|
# Bot stores JWT for user
|
||||||
|
redis.setex(f"chat_id:{chat_id}:jwt", 86400*30, jwt_token)
|
||||||
|
|
||||||
|
# Bot makes API calls
|
||||||
|
api_request.headers['Authorization'] = f'Bearer {jwt_token}'
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Get authenticated user from JWT
|
||||||
|
user_id = getattr(current_request.state, "user_id", None)
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(status_code=401, detail="User must be authenticated")
|
||||||
|
|
||||||
|
service = AuthService(db)
|
||||||
|
result = await service.confirm_telegram_binding(
|
||||||
|
user_id=user_id,
|
||||||
|
chat_id=request.chat_id,
|
||||||
|
code=request.code,
|
||||||
|
username=request.username,
|
||||||
|
first_name=request.first_name,
|
||||||
|
last_name=request.last_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not result.get("success"):
|
||||||
|
raise HTTPException(status_code=400, detail="Binding failed")
|
||||||
|
|
||||||
|
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
|
||||||
|
import json
|
||||||
|
|
||||||
|
redis_client = redis.Redis(
|
||||||
|
host="localhost",
|
||||||
|
port=6379,
|
||||||
|
db=0,
|
||||||
|
decode_responses=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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,
|
||||||
|
summary="Authenticate by Telegram chat_id",
|
||||||
|
)
|
||||||
|
async def telegram_authenticate(
|
||||||
|
chat_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get JWT token for Telegram user (bot authentication).
|
||||||
|
|
||||||
|
**Usage in Bot:**
|
||||||
|
```python
|
||||||
|
# Get token for authenticated user
|
||||||
|
response = api.post("/auth/telegram/authenticate?chat_id=12345")
|
||||||
|
jwt_token = response["jwt_token"]
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
service = AuthService(db)
|
||||||
|
result = await service.authenticate_telegram_user(chat_id=chat_id)
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(status_code=404, detail="Telegram identity not found")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/logout",
|
||||||
|
summary="Logout user",
|
||||||
|
)
|
||||||
|
async def logout(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Revoke session and blacklist tokens.
|
||||||
|
|
||||||
|
**TODO:** Implement token blacklisting in Redis
|
||||||
|
"""
|
||||||
|
|
||||||
|
user_id = getattr(request.state, "user_id", None)
|
||||||
|
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
|
|
||||||
|
# TODO: Add token to Redis blacklist
|
||||||
|
# redis.setex(f"blacklist:{token}", token_expiry_time, "1")
|
||||||
|
|
||||||
|
return {"message": "Logged out successfully"}
|
||||||
329
.history/app/api/auth_20251210221420.py
Normal file
329
.history/app/api/auth_20251210221420.py
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
"""
|
||||||
|
Authentication API Endpoints - Login, Token Management, Telegram Binding
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
|
from typing import Optional
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.db.database import get_db
|
||||||
|
from app.services.auth_service import AuthService
|
||||||
|
from app.security.jwt_manager import jwt_manager
|
||||||
|
import logging
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/v1/auth", tags=["authentication"])
|
||||||
|
|
||||||
|
|
||||||
|
# Request/Response Models
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class LoginResponse(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
refresh_token: str
|
||||||
|
user_id: int
|
||||||
|
expires_in: int # seconds
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramBindingStartRequest(BaseModel):
|
||||||
|
chat_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramBindingStartResponse(BaseModel):
|
||||||
|
code: str
|
||||||
|
expires_in: int # seconds
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramBindingConfirmRequest(BaseModel):
|
||||||
|
code: str
|
||||||
|
chat_id: int
|
||||||
|
username: Optional[str] = None
|
||||||
|
first_name: Optional[str] = None
|
||||||
|
last_name: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramBindingConfirmResponse(BaseModel):
|
||||||
|
success: bool
|
||||||
|
user_id: int
|
||||||
|
jwt_token: str
|
||||||
|
expires_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class TokenRefreshRequest(BaseModel):
|
||||||
|
refresh_token: str
|
||||||
|
|
||||||
|
|
||||||
|
class TokenRefreshResponse(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
expires_in: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/login",
|
||||||
|
response_model=LoginResponse,
|
||||||
|
summary="User login with email & password",
|
||||||
|
)
|
||||||
|
async def login(
|
||||||
|
request: LoginRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> LoginResponse:
|
||||||
|
"""
|
||||||
|
Authenticate user and create session.
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
- access_token: Short-lived JWT (15 min)
|
||||||
|
- refresh_token: Long-lived refresh token (30 days)
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```
|
||||||
|
Authorization: Bearer <access_token>
|
||||||
|
X-Device-Id: device_uuid # For tracking
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
# TODO: Verify email + password
|
||||||
|
# For MVP: Assume credentials are valid
|
||||||
|
|
||||||
|
from app.db.models import User
|
||||||
|
|
||||||
|
user = db.query(User).filter(User.email == request.email).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||||
|
|
||||||
|
service = AuthService(db)
|
||||||
|
access_token, refresh_token = await service.create_session(
|
||||||
|
user_id=user.id,
|
||||||
|
device_id=request.__dict__.get("device_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
return LoginResponse(
|
||||||
|
access_token=access_token,
|
||||||
|
refresh_token=refresh_token,
|
||||||
|
user_id=user.id,
|
||||||
|
expires_in=15 * 60, # 15 minutes
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/refresh",
|
||||||
|
response_model=TokenRefreshResponse,
|
||||||
|
summary="Refresh access token",
|
||||||
|
)
|
||||||
|
async def refresh_token(
|
||||||
|
request: TokenRefreshRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> TokenRefreshResponse:
|
||||||
|
"""
|
||||||
|
Issue new access token using refresh token.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
1. Access token expires
|
||||||
|
2. Send refresh_token to this endpoint
|
||||||
|
3. Receive new access_token (without creating new session)
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
token_payload = jwt_manager.verify_token(request.refresh_token)
|
||||||
|
if token_payload.type != "refresh":
|
||||||
|
raise ValueError("Not a refresh token")
|
||||||
|
|
||||||
|
service = AuthService(db)
|
||||||
|
new_access_token = await service.refresh_access_token(
|
||||||
|
refresh_token=request.refresh_token,
|
||||||
|
user_id=token_payload.sub,
|
||||||
|
)
|
||||||
|
|
||||||
|
return TokenRefreshResponse(
|
||||||
|
access_token=new_access_token,
|
||||||
|
expires_in=15 * 60,
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=401, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/telegram/start",
|
||||||
|
response_model=TelegramBindingStartResponse,
|
||||||
|
summary="Start Telegram binding flow",
|
||||||
|
)
|
||||||
|
async def telegram_binding_start(
|
||||||
|
request: TelegramBindingStartRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Generate binding code for Telegram user.
|
||||||
|
|
||||||
|
**Bot Flow:**
|
||||||
|
1. User sends /start
|
||||||
|
2. Bot calls this endpoint: POST /auth/telegram/start
|
||||||
|
3. Bot receives code and generates link
|
||||||
|
4. Bot sends message with link to user
|
||||||
|
5. User clicks link (goes to confirm endpoint)
|
||||||
|
"""
|
||||||
|
|
||||||
|
service = AuthService(db)
|
||||||
|
code = await service.create_telegram_binding_code(chat_id=request.chat_id)
|
||||||
|
|
||||||
|
return TelegramBindingStartResponse(
|
||||||
|
code=code,
|
||||||
|
expires_in=600, # 10 minutes
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/telegram/confirm",
|
||||||
|
response_model=TelegramBindingConfirmResponse,
|
||||||
|
summary="Confirm Telegram binding",
|
||||||
|
)
|
||||||
|
async def telegram_binding_confirm(
|
||||||
|
request: TelegramBindingConfirmRequest,
|
||||||
|
current_request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Confirm Telegram binding and issue JWT.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
1. User logs in or creates account
|
||||||
|
2. User clicks binding link with code
|
||||||
|
3. Frontend calls this endpoint with code + user context
|
||||||
|
4. Backend creates TelegramIdentity record
|
||||||
|
5. Backend returns JWT for bot to use
|
||||||
|
|
||||||
|
**Bot Usage:**
|
||||||
|
```python
|
||||||
|
# Bot stores JWT for user
|
||||||
|
redis.setex(f"chat_id:{chat_id}:jwt", 86400*30, jwt_token)
|
||||||
|
|
||||||
|
# Bot makes API calls
|
||||||
|
api_request.headers['Authorization'] = f'Bearer {jwt_token}'
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Get authenticated user from JWT
|
||||||
|
user_id = getattr(current_request.state, "user_id", None)
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(status_code=401, detail="User must be authenticated")
|
||||||
|
|
||||||
|
service = AuthService(db)
|
||||||
|
result = await service.confirm_telegram_binding(
|
||||||
|
user_id=user_id,
|
||||||
|
chat_id=request.chat_id,
|
||||||
|
code=request.code,
|
||||||
|
username=request.username,
|
||||||
|
first_name=request.first_name,
|
||||||
|
last_name=request.last_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not result.get("success"):
|
||||||
|
raise HTTPException(status_code=400, detail="Binding failed")
|
||||||
|
|
||||||
|
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,
|
||||||
|
summary="Authenticate by Telegram chat_id",
|
||||||
|
)
|
||||||
|
async def telegram_authenticate(
|
||||||
|
chat_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get JWT token for Telegram user (bot authentication).
|
||||||
|
|
||||||
|
**Usage in Bot:**
|
||||||
|
```python
|
||||||
|
# Get token for authenticated user
|
||||||
|
response = api.post("/auth/telegram/authenticate?chat_id=12345")
|
||||||
|
jwt_token = response["jwt_token"]
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
service = AuthService(db)
|
||||||
|
result = await service.authenticate_telegram_user(chat_id=chat_id)
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(status_code=404, detail="Telegram identity not found")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/logout",
|
||||||
|
summary="Logout user",
|
||||||
|
)
|
||||||
|
async def logout(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Revoke session and blacklist tokens.
|
||||||
|
|
||||||
|
**TODO:** Implement token blacklisting in Redis
|
||||||
|
"""
|
||||||
|
|
||||||
|
user_id = getattr(request.state, "user_id", None)
|
||||||
|
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
|
|
||||||
|
# TODO: Add token to Redis blacklist
|
||||||
|
# redis.setex(f"blacklist:{token}", token_expiry_time, "1")
|
||||||
|
|
||||||
|
return {"message": "Logged out successfully"}
|
||||||
409
.history/app/api/auth_20251210221448.py
Normal file
409
.history/app/api/auth_20251210221448.py
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
"""
|
||||||
|
Authentication API Endpoints - Login, Token Management, Telegram Binding
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
|
from typing import Optional
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.db.database import get_db
|
||||||
|
from app.services.auth_service import AuthService
|
||||||
|
from app.security.jwt_manager import jwt_manager
|
||||||
|
import logging
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/v1/auth", tags=["authentication"])
|
||||||
|
|
||||||
|
|
||||||
|
# Request/Response Models
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class LoginResponse(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
refresh_token: str
|
||||||
|
user_id: int
|
||||||
|
expires_in: int # seconds
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramBindingStartRequest(BaseModel):
|
||||||
|
chat_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramBindingStartResponse(BaseModel):
|
||||||
|
code: str
|
||||||
|
expires_in: int # seconds
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramBindingConfirmRequest(BaseModel):
|
||||||
|
code: str
|
||||||
|
chat_id: int
|
||||||
|
username: Optional[str] = None
|
||||||
|
first_name: Optional[str] = None
|
||||||
|
last_name: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramBindingConfirmResponse(BaseModel):
|
||||||
|
success: bool
|
||||||
|
user_id: int
|
||||||
|
jwt_token: str
|
||||||
|
expires_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class TokenRefreshRequest(BaseModel):
|
||||||
|
refresh_token: str
|
||||||
|
|
||||||
|
|
||||||
|
class TokenRefreshResponse(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
expires_in: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/login",
|
||||||
|
response_model=LoginResponse,
|
||||||
|
summary="User login with email & password",
|
||||||
|
)
|
||||||
|
async def login(
|
||||||
|
request: LoginRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> LoginResponse:
|
||||||
|
"""
|
||||||
|
Authenticate user and create session.
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
- access_token: Short-lived JWT (15 min)
|
||||||
|
- refresh_token: Long-lived refresh token (30 days)
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```
|
||||||
|
Authorization: Bearer <access_token>
|
||||||
|
X-Device-Id: device_uuid # For tracking
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
# TODO: Verify email + password
|
||||||
|
# For MVP: Assume credentials are valid
|
||||||
|
|
||||||
|
from app.db.models import User
|
||||||
|
|
||||||
|
user = db.query(User).filter(User.email == request.email).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||||
|
|
||||||
|
service = AuthService(db)
|
||||||
|
access_token, refresh_token = await service.create_session(
|
||||||
|
user_id=user.id,
|
||||||
|
device_id=request.__dict__.get("device_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
return LoginResponse(
|
||||||
|
access_token=access_token,
|
||||||
|
refresh_token=refresh_token,
|
||||||
|
user_id=user.id,
|
||||||
|
expires_in=15 * 60, # 15 minutes
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/refresh",
|
||||||
|
response_model=TokenRefreshResponse,
|
||||||
|
summary="Refresh access token",
|
||||||
|
)
|
||||||
|
async def refresh_token(
|
||||||
|
request: TokenRefreshRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> TokenRefreshResponse:
|
||||||
|
"""
|
||||||
|
Issue new access token using refresh token.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
1. Access token expires
|
||||||
|
2. Send refresh_token to this endpoint
|
||||||
|
3. Receive new access_token (without creating new session)
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
token_payload = jwt_manager.verify_token(request.refresh_token)
|
||||||
|
if token_payload.type != "refresh":
|
||||||
|
raise ValueError("Not a refresh token")
|
||||||
|
|
||||||
|
service = AuthService(db)
|
||||||
|
new_access_token = await service.refresh_access_token(
|
||||||
|
refresh_token=request.refresh_token,
|
||||||
|
user_id=token_payload.sub,
|
||||||
|
)
|
||||||
|
|
||||||
|
return TokenRefreshResponse(
|
||||||
|
access_token=new_access_token,
|
||||||
|
expires_in=15 * 60,
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=401, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/telegram/start",
|
||||||
|
response_model=TelegramBindingStartResponse,
|
||||||
|
summary="Start Telegram binding flow",
|
||||||
|
)
|
||||||
|
async def telegram_binding_start(
|
||||||
|
request: TelegramBindingStartRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Generate binding code for Telegram user.
|
||||||
|
|
||||||
|
**Bot Flow:**
|
||||||
|
1. User sends /start
|
||||||
|
2. Bot calls this endpoint: POST /auth/telegram/start
|
||||||
|
3. Bot receives code and generates link
|
||||||
|
4. Bot sends message with link to user
|
||||||
|
5. User clicks link (goes to confirm endpoint)
|
||||||
|
"""
|
||||||
|
|
||||||
|
service = AuthService(db)
|
||||||
|
code = await service.create_telegram_binding_code(chat_id=request.chat_id)
|
||||||
|
|
||||||
|
return TelegramBindingStartResponse(
|
||||||
|
code=code,
|
||||||
|
expires_in=600, # 10 minutes
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/telegram/confirm",
|
||||||
|
response_model=TelegramBindingConfirmResponse,
|
||||||
|
summary="Confirm Telegram binding",
|
||||||
|
)
|
||||||
|
async def telegram_binding_confirm(
|
||||||
|
request: TelegramBindingConfirmRequest,
|
||||||
|
current_request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Confirm Telegram binding and issue JWT.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
1. User logs in or creates account
|
||||||
|
2. User clicks binding link with code
|
||||||
|
3. Frontend calls this endpoint with code + user context
|
||||||
|
4. Backend creates TelegramIdentity record
|
||||||
|
5. Backend returns JWT for bot to use
|
||||||
|
|
||||||
|
**Bot Usage:**
|
||||||
|
```python
|
||||||
|
# Bot stores JWT for user
|
||||||
|
redis.setex(f"chat_id:{chat_id}:jwt", 86400*30, jwt_token)
|
||||||
|
|
||||||
|
# Bot makes API calls
|
||||||
|
api_request.headers['Authorization'] = f'Bearer {jwt_token}'
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Get authenticated user from JWT
|
||||||
|
user_id = getattr(current_request.state, "user_id", None)
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(status_code=401, detail="User must be authenticated")
|
||||||
|
|
||||||
|
service = AuthService(db)
|
||||||
|
result = await service.confirm_telegram_binding(
|
||||||
|
user_id=user_id,
|
||||||
|
chat_id=request.chat_id,
|
||||||
|
code=request.code,
|
||||||
|
username=request.username,
|
||||||
|
first_name=request.first_name,
|
||||||
|
last_name=request.last_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not result.get("success"):
|
||||||
|
raise HTTPException(status_code=400, detail="Binding failed")
|
||||||
|
|
||||||
|
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,
|
||||||
|
summary="Authenticate by Telegram chat_id",
|
||||||
|
)
|
||||||
|
async def telegram_authenticate(
|
||||||
|
chat_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get JWT token for Telegram user (bot authentication).
|
||||||
|
|
||||||
|
**Usage in Bot:**
|
||||||
|
```python
|
||||||
|
# Get token for authenticated user
|
||||||
|
response = api.post("/auth/telegram/authenticate?chat_id=12345")
|
||||||
|
jwt_token = response["jwt_token"]
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
service = AuthService(db)
|
||||||
|
result = await service.authenticate_telegram_user(chat_id=chat_id)
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(status_code=404, detail="Telegram identity not found")
|
||||||
|
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
async def logout(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Revoke session and blacklist tokens.
|
||||||
|
|
||||||
|
**TODO:** Implement token blacklisting in Redis
|
||||||
|
"""
|
||||||
|
|
||||||
|
user_id = getattr(request.state, "user_id", None)
|
||||||
|
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
|
|
||||||
|
# TODO: Add token to Redis blacklist
|
||||||
|
# redis.setex(f"blacklist:{token}", token_expiry_time, "1")
|
||||||
|
|
||||||
|
return {"message": "Logged out successfully"}
|
||||||
409
.history/app/api/auth_20251210221526.py
Normal file
409
.history/app/api/auth_20251210221526.py
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
"""
|
||||||
|
Authentication API Endpoints - Login, Token Management, Telegram Binding
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
|
from typing import Optional
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.db.database import get_db
|
||||||
|
from app.services.auth_service import AuthService
|
||||||
|
from app.security.jwt_manager import jwt_manager
|
||||||
|
import logging
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/v1/auth", tags=["authentication"])
|
||||||
|
|
||||||
|
|
||||||
|
# Request/Response Models
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class LoginResponse(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
refresh_token: str
|
||||||
|
user_id: int
|
||||||
|
expires_in: int # seconds
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramBindingStartRequest(BaseModel):
|
||||||
|
chat_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramBindingStartResponse(BaseModel):
|
||||||
|
code: str
|
||||||
|
expires_in: int # seconds
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramBindingConfirmRequest(BaseModel):
|
||||||
|
code: str
|
||||||
|
chat_id: int
|
||||||
|
username: Optional[str] = None
|
||||||
|
first_name: Optional[str] = None
|
||||||
|
last_name: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramBindingConfirmResponse(BaseModel):
|
||||||
|
success: bool
|
||||||
|
user_id: int
|
||||||
|
jwt_token: str
|
||||||
|
expires_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class TokenRefreshRequest(BaseModel):
|
||||||
|
refresh_token: str
|
||||||
|
|
||||||
|
|
||||||
|
class TokenRefreshResponse(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
expires_in: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/login",
|
||||||
|
response_model=LoginResponse,
|
||||||
|
summary="User login with email & password",
|
||||||
|
)
|
||||||
|
async def login(
|
||||||
|
request: LoginRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> LoginResponse:
|
||||||
|
"""
|
||||||
|
Authenticate user and create session.
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
- access_token: Short-lived JWT (15 min)
|
||||||
|
- refresh_token: Long-lived refresh token (30 days)
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```
|
||||||
|
Authorization: Bearer <access_token>
|
||||||
|
X-Device-Id: device_uuid # For tracking
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
# TODO: Verify email + password
|
||||||
|
# For MVP: Assume credentials are valid
|
||||||
|
|
||||||
|
from app.db.models import User
|
||||||
|
|
||||||
|
user = db.query(User).filter(User.email == request.email).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||||
|
|
||||||
|
service = AuthService(db)
|
||||||
|
access_token, refresh_token = await service.create_session(
|
||||||
|
user_id=user.id,
|
||||||
|
device_id=request.__dict__.get("device_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
return LoginResponse(
|
||||||
|
access_token=access_token,
|
||||||
|
refresh_token=refresh_token,
|
||||||
|
user_id=user.id,
|
||||||
|
expires_in=15 * 60, # 15 minutes
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/refresh",
|
||||||
|
response_model=TokenRefreshResponse,
|
||||||
|
summary="Refresh access token",
|
||||||
|
)
|
||||||
|
async def refresh_token(
|
||||||
|
request: TokenRefreshRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> TokenRefreshResponse:
|
||||||
|
"""
|
||||||
|
Issue new access token using refresh token.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
1. Access token expires
|
||||||
|
2. Send refresh_token to this endpoint
|
||||||
|
3. Receive new access_token (without creating new session)
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
token_payload = jwt_manager.verify_token(request.refresh_token)
|
||||||
|
if token_payload.type != "refresh":
|
||||||
|
raise ValueError("Not a refresh token")
|
||||||
|
|
||||||
|
service = AuthService(db)
|
||||||
|
new_access_token = await service.refresh_access_token(
|
||||||
|
refresh_token=request.refresh_token,
|
||||||
|
user_id=token_payload.sub,
|
||||||
|
)
|
||||||
|
|
||||||
|
return TokenRefreshResponse(
|
||||||
|
access_token=new_access_token,
|
||||||
|
expires_in=15 * 60,
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=401, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/telegram/start",
|
||||||
|
response_model=TelegramBindingStartResponse,
|
||||||
|
summary="Start Telegram binding flow",
|
||||||
|
)
|
||||||
|
async def telegram_binding_start(
|
||||||
|
request: TelegramBindingStartRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Generate binding code for Telegram user.
|
||||||
|
|
||||||
|
**Bot Flow:**
|
||||||
|
1. User sends /start
|
||||||
|
2. Bot calls this endpoint: POST /auth/telegram/start
|
||||||
|
3. Bot receives code and generates link
|
||||||
|
4. Bot sends message with link to user
|
||||||
|
5. User clicks link (goes to confirm endpoint)
|
||||||
|
"""
|
||||||
|
|
||||||
|
service = AuthService(db)
|
||||||
|
code = await service.create_telegram_binding_code(chat_id=request.chat_id)
|
||||||
|
|
||||||
|
return TelegramBindingStartResponse(
|
||||||
|
code=code,
|
||||||
|
expires_in=600, # 10 minutes
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/telegram/confirm",
|
||||||
|
response_model=TelegramBindingConfirmResponse,
|
||||||
|
summary="Confirm Telegram binding",
|
||||||
|
)
|
||||||
|
async def telegram_binding_confirm(
|
||||||
|
request: TelegramBindingConfirmRequest,
|
||||||
|
current_request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Confirm Telegram binding and issue JWT.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
1. User logs in or creates account
|
||||||
|
2. User clicks binding link with code
|
||||||
|
3. Frontend calls this endpoint with code + user context
|
||||||
|
4. Backend creates TelegramIdentity record
|
||||||
|
5. Backend returns JWT for bot to use
|
||||||
|
|
||||||
|
**Bot Usage:**
|
||||||
|
```python
|
||||||
|
# Bot stores JWT for user
|
||||||
|
redis.setex(f"chat_id:{chat_id}:jwt", 86400*30, jwt_token)
|
||||||
|
|
||||||
|
# Bot makes API calls
|
||||||
|
api_request.headers['Authorization'] = f'Bearer {jwt_token}'
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Get authenticated user from JWT
|
||||||
|
user_id = getattr(current_request.state, "user_id", None)
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(status_code=401, detail="User must be authenticated")
|
||||||
|
|
||||||
|
service = AuthService(db)
|
||||||
|
result = await service.confirm_telegram_binding(
|
||||||
|
user_id=user_id,
|
||||||
|
chat_id=request.chat_id,
|
||||||
|
code=request.code,
|
||||||
|
username=request.username,
|
||||||
|
first_name=request.first_name,
|
||||||
|
last_name=request.last_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not result.get("success"):
|
||||||
|
raise HTTPException(status_code=400, detail="Binding failed")
|
||||||
|
|
||||||
|
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,
|
||||||
|
summary="Authenticate by Telegram chat_id",
|
||||||
|
)
|
||||||
|
async def telegram_authenticate(
|
||||||
|
chat_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get JWT token for Telegram user (bot authentication).
|
||||||
|
|
||||||
|
**Usage in Bot:**
|
||||||
|
```python
|
||||||
|
# Get token for authenticated user
|
||||||
|
response = api.post("/auth/telegram/authenticate?chat_id=12345")
|
||||||
|
jwt_token = response["jwt_token"]
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
service = AuthService(db)
|
||||||
|
result = await service.authenticate_telegram_user(chat_id=chat_id)
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(status_code=404, detail="Telegram identity not found")
|
||||||
|
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
async def logout(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Revoke session and blacklist tokens.
|
||||||
|
|
||||||
|
**TODO:** Implement token blacklisting in Redis
|
||||||
|
"""
|
||||||
|
|
||||||
|
user_id = getattr(request.state, "user_id", None)
|
||||||
|
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
|
|
||||||
|
# TODO: Add token to Redis blacklist
|
||||||
|
# redis.setex(f"blacklist:{token}", token_expiry_time, "1")
|
||||||
|
|
||||||
|
return {"message": "Logged out successfully"}
|
||||||
362
.history/app/bot/client_20251210221303.py
Normal file
362
.history/app/bot/client_20251210221303.py
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
"""
|
||||||
|
Telegram Bot - API-First Client
|
||||||
|
All database operations go through API endpoints, not direct SQLAlchemy.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
from decimal import Decimal
|
||||||
|
import aiohttp
|
||||||
|
import time
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramBotClient:
|
||||||
|
"""
|
||||||
|
Telegram Bot that communicates exclusively via API calls.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- User authentication via JWT tokens stored in Redis
|
||||||
|
- All operations through API (no direct DB access)
|
||||||
|
- Async HTTP requests with aiohttp
|
||||||
|
- Event listening via Redis Streams
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, bot_token: str, api_base_url: str, redis_client: redis.Redis):
|
||||||
|
self.bot = Bot(token=bot_token)
|
||||||
|
self.dp = Dispatcher()
|
||||||
|
self.api_base_url = api_base_url
|
||||||
|
self.redis_client = redis_client
|
||||||
|
self.session: Optional[aiohttp.ClientSession] = None
|
||||||
|
|
||||||
|
# Register handlers
|
||||||
|
self._setup_handlers()
|
||||||
|
|
||||||
|
def _setup_handlers(self):
|
||||||
|
"""Register message handlers"""
|
||||||
|
self.dp.message.register(self.cmd_start, Command("start"))
|
||||||
|
self.dp.message.register(self.cmd_help, Command("help"))
|
||||||
|
self.dp.message.register(self.cmd_balance, Command("balance"))
|
||||||
|
self.dp.message.register(self.cmd_add_transaction, Command("add"))
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
"""Start bot polling"""
|
||||||
|
self.session = aiohttp.ClientSession()
|
||||||
|
logger.info("Telegram bot started")
|
||||||
|
|
||||||
|
# Start polling
|
||||||
|
try:
|
||||||
|
await self.dp.start_polling(self.bot)
|
||||||
|
finally:
|
||||||
|
await self.session.close()
|
||||||
|
|
||||||
|
# ========== Handler: /start (Binding) ==========
|
||||||
|
async def cmd_start(self, message: Message):
|
||||||
|
"""
|
||||||
|
/start - Begin Telegram binding process.
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|
# Check if already bound
|
||||||
|
jwt_key = f"chat_id:{chat_id}:jwt"
|
||||||
|
existing_token = self.redis_client.get(jwt_key)
|
||||||
|
|
||||||
|
if existing_token:
|
||||||
|
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:
|
||||||
|
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_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
|
||||||
|
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\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}", 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:
|
||||||
|
- User must be bound (JWT token in Redis)
|
||||||
|
- API call with JWT auth
|
||||||
|
"""
|
||||||
|
chat_id = message.chat.id
|
||||||
|
|
||||||
|
# Get JWT token
|
||||||
|
jwt_token = self._get_user_jwt(chat_id)
|
||||||
|
if not jwt_token:
|
||||||
|
await message.answer("❌ Not connected. Use /start to bind your account.")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Call API: GET /api/v1/wallets/summary?family_id=1
|
||||||
|
wallets = await self._api_call(
|
||||||
|
method="GET",
|
||||||
|
endpoint="/api/v1/wallets/summary",
|
||||||
|
jwt_token=jwt_token,
|
||||||
|
params={"family_id": 1}, # TODO: Get from context
|
||||||
|
)
|
||||||
|
|
||||||
|
# Format response
|
||||||
|
response = "💰 **Your Wallets:**\n\n"
|
||||||
|
for wallet in wallets:
|
||||||
|
response += f"📊 {wallet['name']}: ${wallet['balance']}\n"
|
||||||
|
|
||||||
|
await message.answer(response, parse_mode="Markdown")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Balance fetch error: {e}")
|
||||||
|
await message.answer("❌ Could not fetch balance. Try again later.")
|
||||||
|
|
||||||
|
# ========== Handler: /add (Create Transaction) ==========
|
||||||
|
async def cmd_add_transaction(self, message: Message):
|
||||||
|
"""
|
||||||
|
/add - Create new transaction (interactive).
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
1. Ask for amount
|
||||||
|
2. Ask for category
|
||||||
|
3. Ask for wallet (from/to)
|
||||||
|
4. Create transaction via API
|
||||||
|
"""
|
||||||
|
|
||||||
|
chat_id = message.chat.id
|
||||||
|
jwt_token = self._get_user_jwt(chat_id)
|
||||||
|
|
||||||
|
if not jwt_token:
|
||||||
|
await message.answer("❌ Not connected. Use /start first.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Store conversation state in Redis
|
||||||
|
state_key = f"chat_id:{chat_id}:state"
|
||||||
|
self.redis_client.setex(state_key, 300, json.dumps({
|
||||||
|
"action": "add_transaction",
|
||||||
|
"step": 1, # Waiting for amount
|
||||||
|
}))
|
||||||
|
|
||||||
|
await message.answer("💵 How much?\n\nEnter amount (e.g., 50.00)")
|
||||||
|
|
||||||
|
async def handle_transaction_input(self, message: Message, state: Dict[str, Any]):
|
||||||
|
"""Handle transaction creation in steps"""
|
||||||
|
chat_id = message.chat.id
|
||||||
|
jwt_token = self._get_user_jwt(chat_id)
|
||||||
|
|
||||||
|
step = state.get("step", 1)
|
||||||
|
|
||||||
|
if step == 1:
|
||||||
|
# Amount entered
|
||||||
|
try:
|
||||||
|
amount = Decimal(message.text)
|
||||||
|
except:
|
||||||
|
await message.answer("❌ Invalid amount. Try again.")
|
||||||
|
return
|
||||||
|
|
||||||
|
state["amount"] = float(amount)
|
||||||
|
state["step"] = 2
|
||||||
|
self.redis_client.setex(f"chat_id:{chat_id}:state", 300, json.dumps(state))
|
||||||
|
|
||||||
|
await message.answer("📂 Which category?\n\n/food /transport /other")
|
||||||
|
|
||||||
|
elif step == 2:
|
||||||
|
# Category selected
|
||||||
|
state["category"] = message.text
|
||||||
|
state["step"] = 3
|
||||||
|
self.redis_client.setex(f"chat_id:{chat_id}:state", 300, json.dumps(state))
|
||||||
|
|
||||||
|
await message.answer("💬 Any notes?\n\n(or /skip)")
|
||||||
|
|
||||||
|
elif step == 3:
|
||||||
|
# Notes entered (or skipped)
|
||||||
|
state["notes"] = message.text if message.text != "/skip" else ""
|
||||||
|
|
||||||
|
# Create transaction via API
|
||||||
|
try:
|
||||||
|
result = await self._api_call(
|
||||||
|
method="POST",
|
||||||
|
endpoint="/api/v1/transactions",
|
||||||
|
jwt_token=jwt_token,
|
||||||
|
data={
|
||||||
|
"family_id": 1,
|
||||||
|
"from_wallet_id": 10,
|
||||||
|
"amount": state["amount"],
|
||||||
|
"category_id": 5, # TODO: Map category
|
||||||
|
"description": state["category"],
|
||||||
|
"notes": state["notes"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
tx_id = result.get("id")
|
||||||
|
await message.answer(f"✅ Transaction #{tx_id} created!")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Transaction creation error: {e}")
|
||||||
|
await message.answer("❌ Creation failed. Try again.")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Clean up state
|
||||||
|
self.redis_client.delete(f"chat_id:{chat_id}:state")
|
||||||
|
|
||||||
|
# ========== Handler: /help ==========
|
||||||
|
async def cmd_help(self, message: Message):
|
||||||
|
"""Show available commands"""
|
||||||
|
help_text = """
|
||||||
|
🤖 **Finance Bot Commands:**
|
||||||
|
|
||||||
|
/start - Bind your Telegram account
|
||||||
|
/balance - Show wallet balances
|
||||||
|
/add - Add new transaction
|
||||||
|
/reports - View reports (daily/weekly/monthly)
|
||||||
|
/help - This message
|
||||||
|
"""
|
||||||
|
await message.answer(help_text, parse_mode="Markdown")
|
||||||
|
|
||||||
|
# ========== API Communication Methods ==========
|
||||||
|
async def _api_call(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
endpoint: str,
|
||||||
|
data: Dict = None,
|
||||||
|
params: Dict = None,
|
||||||
|
jwt_token: Optional[str] = None,
|
||||||
|
use_jwt: bool = True,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Make HTTP request to API with proper auth headers.
|
||||||
|
|
||||||
|
Headers:
|
||||||
|
- Authorization: Bearer <jwt_token>
|
||||||
|
- X-Client-Id: telegram_bot
|
||||||
|
- X-Signature: HMAC_SHA256(...)
|
||||||
|
- X-Timestamp: unix timestamp
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not self.session:
|
||||||
|
raise RuntimeError("Session not initialized")
|
||||||
|
|
||||||
|
# Build headers
|
||||||
|
headers = {
|
||||||
|
"X-Client-Id": "telegram_bot",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add JWT if provided
|
||||||
|
if use_jwt and jwt_token:
|
||||||
|
headers["Authorization"] = f"Bearer {jwt_token}"
|
||||||
|
|
||||||
|
# Add HMAC signature
|
||||||
|
timestamp = int(time.time())
|
||||||
|
headers["X-Timestamp"] = str(timestamp)
|
||||||
|
|
||||||
|
signature = hmac_manager.create_signature(
|
||||||
|
method=method,
|
||||||
|
endpoint=endpoint,
|
||||||
|
timestamp=timestamp,
|
||||||
|
body=data,
|
||||||
|
)
|
||||||
|
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()
|
||||||
|
|
||||||
|
def _get_user_jwt(self, chat_id: int) -> Optional[str]:
|
||||||
|
"""Get JWT token for chat_id from Redis"""
|
||||||
|
jwt_key = f"chat_id:{chat_id}:jwt"
|
||||||
|
token = self.redis_client.get(jwt_key)
|
||||||
|
return token.decode() if token else None
|
||||||
|
|
||||||
|
async def send_notification(self, chat_id: int, message: str):
|
||||||
|
"""Send notification to user"""
|
||||||
|
try:
|
||||||
|
await self.bot.send_message(chat_id=chat_id, text=message)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send notification to {chat_id}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# Bot factory
|
||||||
|
async def create_telegram_bot(
|
||||||
|
bot_token: str,
|
||||||
|
api_base_url: str,
|
||||||
|
redis_client: redis.Redis,
|
||||||
|
) -> TelegramBotClient:
|
||||||
|
"""Create and start Telegram bot"""
|
||||||
|
bot = TelegramBotClient(bot_token, api_base_url, redis_client)
|
||||||
|
return bot
|
||||||
414
.history/app/bot/client_20251210221313.py
Normal file
414
.history/app/bot/client_20251210221313.py
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
"""
|
||||||
|
Telegram Bot - API-First Client
|
||||||
|
All database operations go through API endpoints, not direct SQLAlchemy.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
from decimal import Decimal
|
||||||
|
import aiohttp
|
||||||
|
import time
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramBotClient:
|
||||||
|
"""
|
||||||
|
Telegram Bot that communicates exclusively via API calls.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- User authentication via JWT tokens stored in Redis
|
||||||
|
- All operations through API (no direct DB access)
|
||||||
|
- Async HTTP requests with aiohttp
|
||||||
|
- Event listening via Redis Streams
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, bot_token: str, api_base_url: str, redis_client: redis.Redis):
|
||||||
|
self.bot = Bot(token=bot_token)
|
||||||
|
self.dp = Dispatcher()
|
||||||
|
self.api_base_url = api_base_url
|
||||||
|
self.redis_client = redis_client
|
||||||
|
self.session: Optional[aiohttp.ClientSession] = None
|
||||||
|
|
||||||
|
# Register handlers
|
||||||
|
self._setup_handlers()
|
||||||
|
|
||||||
|
def _setup_handlers(self):
|
||||||
|
"""Register message handlers"""
|
||||||
|
self.dp.message.register(self.cmd_start, Command("start"))
|
||||||
|
self.dp.message.register(self.cmd_help, Command("help"))
|
||||||
|
self.dp.message.register(self.cmd_balance, Command("balance"))
|
||||||
|
self.dp.message.register(self.cmd_add_transaction, Command("add"))
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
"""Start bot polling"""
|
||||||
|
self.session = aiohttp.ClientSession()
|
||||||
|
logger.info("Telegram bot started")
|
||||||
|
|
||||||
|
# Start polling
|
||||||
|
try:
|
||||||
|
await self.dp.start_polling(self.bot)
|
||||||
|
finally:
|
||||||
|
await self.session.close()
|
||||||
|
|
||||||
|
# ========== Handler: /start (Binding) ==========
|
||||||
|
async def cmd_start(self, message: Message):
|
||||||
|
"""
|
||||||
|
/start - Begin Telegram binding process.
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|
# Check if already bound
|
||||||
|
jwt_key = f"chat_id:{chat_id}:jwt"
|
||||||
|
existing_token = self.redis_client.get(jwt_key)
|
||||||
|
|
||||||
|
if existing_token:
|
||||||
|
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:
|
||||||
|
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_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
|
||||||
|
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\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}", 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:**
|
||||||
|
- 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 from Redis
|
||||||
|
jwt_token = self._get_user_jwt(chat_id)
|
||||||
|
|
||||||
|
if not jwt_token:
|
||||||
|
# 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/accounts?family_id=1
|
||||||
|
accounts_response = await self._api_call(
|
||||||
|
method="GET",
|
||||||
|
endpoint="/api/v1/accounts",
|
||||||
|
jwt_token=jwt_token,
|
||||||
|
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 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 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):
|
||||||
|
"""
|
||||||
|
/add - Create new transaction (interactive).
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
1. Ask for amount
|
||||||
|
2. Ask for category
|
||||||
|
3. Ask for wallet (from/to)
|
||||||
|
4. Create transaction via API
|
||||||
|
"""
|
||||||
|
|
||||||
|
chat_id = message.chat.id
|
||||||
|
jwt_token = self._get_user_jwt(chat_id)
|
||||||
|
|
||||||
|
if not jwt_token:
|
||||||
|
await message.answer("❌ Not connected. Use /start first.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Store conversation state in Redis
|
||||||
|
state_key = f"chat_id:{chat_id}:state"
|
||||||
|
self.redis_client.setex(state_key, 300, json.dumps({
|
||||||
|
"action": "add_transaction",
|
||||||
|
"step": 1, # Waiting for amount
|
||||||
|
}))
|
||||||
|
|
||||||
|
await message.answer("💵 How much?\n\nEnter amount (e.g., 50.00)")
|
||||||
|
|
||||||
|
async def handle_transaction_input(self, message: Message, state: Dict[str, Any]):
|
||||||
|
"""Handle transaction creation in steps"""
|
||||||
|
chat_id = message.chat.id
|
||||||
|
jwt_token = self._get_user_jwt(chat_id)
|
||||||
|
|
||||||
|
step = state.get("step", 1)
|
||||||
|
|
||||||
|
if step == 1:
|
||||||
|
# Amount entered
|
||||||
|
try:
|
||||||
|
amount = Decimal(message.text)
|
||||||
|
except:
|
||||||
|
await message.answer("❌ Invalid amount. Try again.")
|
||||||
|
return
|
||||||
|
|
||||||
|
state["amount"] = float(amount)
|
||||||
|
state["step"] = 2
|
||||||
|
self.redis_client.setex(f"chat_id:{chat_id}:state", 300, json.dumps(state))
|
||||||
|
|
||||||
|
await message.answer("📂 Which category?\n\n/food /transport /other")
|
||||||
|
|
||||||
|
elif step == 2:
|
||||||
|
# Category selected
|
||||||
|
state["category"] = message.text
|
||||||
|
state["step"] = 3
|
||||||
|
self.redis_client.setex(f"chat_id:{chat_id}:state", 300, json.dumps(state))
|
||||||
|
|
||||||
|
await message.answer("💬 Any notes?\n\n(or /skip)")
|
||||||
|
|
||||||
|
elif step == 3:
|
||||||
|
# Notes entered (or skipped)
|
||||||
|
state["notes"] = message.text if message.text != "/skip" else ""
|
||||||
|
|
||||||
|
# Create transaction via API
|
||||||
|
try:
|
||||||
|
result = await self._api_call(
|
||||||
|
method="POST",
|
||||||
|
endpoint="/api/v1/transactions",
|
||||||
|
jwt_token=jwt_token,
|
||||||
|
data={
|
||||||
|
"family_id": 1,
|
||||||
|
"from_wallet_id": 10,
|
||||||
|
"amount": state["amount"],
|
||||||
|
"category_id": 5, # TODO: Map category
|
||||||
|
"description": state["category"],
|
||||||
|
"notes": state["notes"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
tx_id = result.get("id")
|
||||||
|
await message.answer(f"✅ Transaction #{tx_id} created!")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Transaction creation error: {e}")
|
||||||
|
await message.answer("❌ Creation failed. Try again.")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Clean up state
|
||||||
|
self.redis_client.delete(f"chat_id:{chat_id}:state")
|
||||||
|
|
||||||
|
# ========== Handler: /help ==========
|
||||||
|
async def cmd_help(self, message: Message):
|
||||||
|
"""Show available commands"""
|
||||||
|
help_text = """
|
||||||
|
🤖 **Finance Bot Commands:**
|
||||||
|
|
||||||
|
/start - Bind your Telegram account
|
||||||
|
/balance - Show wallet balances
|
||||||
|
/add - Add new transaction
|
||||||
|
/reports - View reports (daily/weekly/monthly)
|
||||||
|
/help - This message
|
||||||
|
"""
|
||||||
|
await message.answer(help_text, parse_mode="Markdown")
|
||||||
|
|
||||||
|
# ========== API Communication Methods ==========
|
||||||
|
async def _api_call(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
endpoint: str,
|
||||||
|
data: Dict = None,
|
||||||
|
params: Dict = None,
|
||||||
|
jwt_token: Optional[str] = None,
|
||||||
|
use_jwt: bool = True,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Make HTTP request to API with proper auth headers.
|
||||||
|
|
||||||
|
Headers:
|
||||||
|
- Authorization: Bearer <jwt_token>
|
||||||
|
- X-Client-Id: telegram_bot
|
||||||
|
- X-Signature: HMAC_SHA256(...)
|
||||||
|
- X-Timestamp: unix timestamp
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not self.session:
|
||||||
|
raise RuntimeError("Session not initialized")
|
||||||
|
|
||||||
|
# Build headers
|
||||||
|
headers = {
|
||||||
|
"X-Client-Id": "telegram_bot",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add JWT if provided
|
||||||
|
if use_jwt and jwt_token:
|
||||||
|
headers["Authorization"] = f"Bearer {jwt_token}"
|
||||||
|
|
||||||
|
# Add HMAC signature
|
||||||
|
timestamp = int(time.time())
|
||||||
|
headers["X-Timestamp"] = str(timestamp)
|
||||||
|
|
||||||
|
signature = hmac_manager.create_signature(
|
||||||
|
method=method,
|
||||||
|
endpoint=endpoint,
|
||||||
|
timestamp=timestamp,
|
||||||
|
body=data,
|
||||||
|
)
|
||||||
|
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()
|
||||||
|
|
||||||
|
def _get_user_jwt(self, chat_id: int) -> Optional[str]:
|
||||||
|
"""Get JWT token for chat_id from Redis"""
|
||||||
|
jwt_key = f"chat_id:{chat_id}:jwt"
|
||||||
|
token = self.redis_client.get(jwt_key)
|
||||||
|
return token.decode() if token else None
|
||||||
|
|
||||||
|
async def send_notification(self, chat_id: int, message: str):
|
||||||
|
"""Send notification to user"""
|
||||||
|
try:
|
||||||
|
await self.bot.send_message(chat_id=chat_id, text=message)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send notification to {chat_id}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# Bot factory
|
||||||
|
async def create_telegram_bot(
|
||||||
|
bot_token: str,
|
||||||
|
api_base_url: str,
|
||||||
|
redis_client: redis.Redis,
|
||||||
|
) -> TelegramBotClient:
|
||||||
|
"""Create and start Telegram bot"""
|
||||||
|
bot = TelegramBotClient(bot_token, api_base_url, redis_client)
|
||||||
|
return bot
|
||||||
449
.history/app/bot/client_20251210221319.py
Normal file
449
.history/app/bot/client_20251210221319.py
Normal file
@@ -0,0 +1,449 @@
|
|||||||
|
"""
|
||||||
|
Telegram Bot - API-First Client
|
||||||
|
All database operations go through API endpoints, not direct SQLAlchemy.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
from decimal import Decimal
|
||||||
|
import aiohttp
|
||||||
|
import time
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramBotClient:
|
||||||
|
"""
|
||||||
|
Telegram Bot that communicates exclusively via API calls.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- User authentication via JWT tokens stored in Redis
|
||||||
|
- All operations through API (no direct DB access)
|
||||||
|
- Async HTTP requests with aiohttp
|
||||||
|
- Event listening via Redis Streams
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, bot_token: str, api_base_url: str, redis_client: redis.Redis):
|
||||||
|
self.bot = Bot(token=bot_token)
|
||||||
|
self.dp = Dispatcher()
|
||||||
|
self.api_base_url = api_base_url
|
||||||
|
self.redis_client = redis_client
|
||||||
|
self.session: Optional[aiohttp.ClientSession] = None
|
||||||
|
|
||||||
|
# Register handlers
|
||||||
|
self._setup_handlers()
|
||||||
|
|
||||||
|
def _setup_handlers(self):
|
||||||
|
"""Register message handlers"""
|
||||||
|
self.dp.message.register(self.cmd_start, Command("start"))
|
||||||
|
self.dp.message.register(self.cmd_help, Command("help"))
|
||||||
|
self.dp.message.register(self.cmd_balance, Command("balance"))
|
||||||
|
self.dp.message.register(self.cmd_add_transaction, Command("add"))
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
"""Start bot polling"""
|
||||||
|
self.session = aiohttp.ClientSession()
|
||||||
|
logger.info("Telegram bot started")
|
||||||
|
|
||||||
|
# Start polling
|
||||||
|
try:
|
||||||
|
await self.dp.start_polling(self.bot)
|
||||||
|
finally:
|
||||||
|
await self.session.close()
|
||||||
|
|
||||||
|
# ========== Handler: /start (Binding) ==========
|
||||||
|
async def cmd_start(self, message: Message):
|
||||||
|
"""
|
||||||
|
/start - Begin Telegram binding process.
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|
# Check if already bound
|
||||||
|
jwt_key = f"chat_id:{chat_id}:jwt"
|
||||||
|
existing_token = self.redis_client.get(jwt_key)
|
||||||
|
|
||||||
|
if existing_token:
|
||||||
|
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:
|
||||||
|
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_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
|
||||||
|
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\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}", 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:**
|
||||||
|
- 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 from Redis
|
||||||
|
jwt_token = self._get_user_jwt(chat_id)
|
||||||
|
|
||||||
|
if not jwt_token:
|
||||||
|
# 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/accounts?family_id=1
|
||||||
|
accounts_response = await self._api_call(
|
||||||
|
method="GET",
|
||||||
|
endpoint="/api/v1/accounts",
|
||||||
|
jwt_token=jwt_token,
|
||||||
|
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 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 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):
|
||||||
|
"""
|
||||||
|
/add - Create new transaction (interactive).
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
1. Ask for amount
|
||||||
|
2. Ask for category
|
||||||
|
3. Ask for wallet (from/to)
|
||||||
|
4. Create transaction via API
|
||||||
|
"""
|
||||||
|
|
||||||
|
chat_id = message.chat.id
|
||||||
|
jwt_token = self._get_user_jwt(chat_id)
|
||||||
|
|
||||||
|
if not jwt_token:
|
||||||
|
await message.answer("❌ Not connected. Use /start first.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Store conversation state in Redis
|
||||||
|
state_key = f"chat_id:{chat_id}:state"
|
||||||
|
self.redis_client.setex(state_key, 300, json.dumps({
|
||||||
|
"action": "add_transaction",
|
||||||
|
"step": 1, # Waiting for amount
|
||||||
|
}))
|
||||||
|
|
||||||
|
await message.answer("💵 How much?\n\nEnter amount (e.g., 50.00)")
|
||||||
|
|
||||||
|
async def handle_transaction_input(self, message: Message, state: Dict[str, Any]):
|
||||||
|
"""Handle transaction creation in steps"""
|
||||||
|
chat_id = message.chat.id
|
||||||
|
jwt_token = self._get_user_jwt(chat_id)
|
||||||
|
|
||||||
|
step = state.get("step", 1)
|
||||||
|
|
||||||
|
if step == 1:
|
||||||
|
# Amount entered
|
||||||
|
try:
|
||||||
|
amount = Decimal(message.text)
|
||||||
|
except:
|
||||||
|
await message.answer("❌ Invalid amount. Try again.")
|
||||||
|
return
|
||||||
|
|
||||||
|
state["amount"] = float(amount)
|
||||||
|
state["step"] = 2
|
||||||
|
self.redis_client.setex(f"chat_id:{chat_id}:state", 300, json.dumps(state))
|
||||||
|
|
||||||
|
await message.answer("📂 Which category?\n\n/food /transport /other")
|
||||||
|
|
||||||
|
elif step == 2:
|
||||||
|
# Category selected
|
||||||
|
state["category"] = message.text
|
||||||
|
state["step"] = 3
|
||||||
|
self.redis_client.setex(f"chat_id:{chat_id}:state", 300, json.dumps(state))
|
||||||
|
|
||||||
|
await message.answer("💬 Any notes?\n\n(or /skip)")
|
||||||
|
|
||||||
|
elif step == 3:
|
||||||
|
# Notes entered (or skipped)
|
||||||
|
state["notes"] = message.text if message.text != "/skip" else ""
|
||||||
|
|
||||||
|
# Create transaction via API
|
||||||
|
try:
|
||||||
|
result = await self._api_call(
|
||||||
|
method="POST",
|
||||||
|
endpoint="/api/v1/transactions",
|
||||||
|
jwt_token=jwt_token,
|
||||||
|
data={
|
||||||
|
"family_id": 1,
|
||||||
|
"from_wallet_id": 10,
|
||||||
|
"amount": state["amount"],
|
||||||
|
"category_id": 5, # TODO: Map category
|
||||||
|
"description": state["category"],
|
||||||
|
"notes": state["notes"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
tx_id = result.get("id")
|
||||||
|
await message.answer(f"✅ Transaction #{tx_id} created!")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Transaction creation error: {e}")
|
||||||
|
await message.answer("❌ Creation failed. Try again.")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Clean up state
|
||||||
|
self.redis_client.delete(f"chat_id:{chat_id}:state")
|
||||||
|
|
||||||
|
# ========== Handler: /help ==========
|
||||||
|
async def cmd_help(self, message: Message):
|
||||||
|
"""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:**
|
||||||
|
|
||||||
|
🔗 **Account**
|
||||||
|
/start - Re-bind account (if needed)
|
||||||
|
/balance - Show all account balances
|
||||||
|
/settings - Account settings
|
||||||
|
|
||||||
|
💰 **Transactions**
|
||||||
|
/add - Add new transaction
|
||||||
|
/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 ==========
|
||||||
|
async def _api_call(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
endpoint: str,
|
||||||
|
data: Dict = None,
|
||||||
|
params: Dict = None,
|
||||||
|
jwt_token: Optional[str] = None,
|
||||||
|
use_jwt: bool = True,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Make HTTP request to API with proper auth headers.
|
||||||
|
|
||||||
|
Headers:
|
||||||
|
- Authorization: Bearer <jwt_token>
|
||||||
|
- X-Client-Id: telegram_bot
|
||||||
|
- X-Signature: HMAC_SHA256(...)
|
||||||
|
- X-Timestamp: unix timestamp
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not self.session:
|
||||||
|
raise RuntimeError("Session not initialized")
|
||||||
|
|
||||||
|
# Build headers
|
||||||
|
headers = {
|
||||||
|
"X-Client-Id": "telegram_bot",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add JWT if provided
|
||||||
|
if use_jwt and jwt_token:
|
||||||
|
headers["Authorization"] = f"Bearer {jwt_token}"
|
||||||
|
|
||||||
|
# Add HMAC signature
|
||||||
|
timestamp = int(time.time())
|
||||||
|
headers["X-Timestamp"] = str(timestamp)
|
||||||
|
|
||||||
|
signature = hmac_manager.create_signature(
|
||||||
|
method=method,
|
||||||
|
endpoint=endpoint,
|
||||||
|
timestamp=timestamp,
|
||||||
|
body=data,
|
||||||
|
)
|
||||||
|
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()
|
||||||
|
|
||||||
|
def _get_user_jwt(self, chat_id: int) -> Optional[str]:
|
||||||
|
"""Get JWT token for chat_id from Redis"""
|
||||||
|
jwt_key = f"chat_id:{chat_id}:jwt"
|
||||||
|
token = self.redis_client.get(jwt_key)
|
||||||
|
return token.decode() if token else None
|
||||||
|
|
||||||
|
async def send_notification(self, chat_id: int, message: str):
|
||||||
|
"""Send notification to user"""
|
||||||
|
try:
|
||||||
|
await self.bot.send_message(chat_id=chat_id, text=message)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send notification to {chat_id}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# Bot factory
|
||||||
|
async def create_telegram_bot(
|
||||||
|
bot_token: str,
|
||||||
|
api_base_url: str,
|
||||||
|
redis_client: redis.Redis,
|
||||||
|
) -> TelegramBotClient:
|
||||||
|
"""Create and start Telegram bot"""
|
||||||
|
bot = TelegramBotClient(bot_token, api_base_url, redis_client)
|
||||||
|
return bot
|
||||||
479
.history/app/bot/client_20251210221328.py
Normal file
479
.history/app/bot/client_20251210221328.py
Normal file
@@ -0,0 +1,479 @@
|
|||||||
|
"""
|
||||||
|
Telegram Bot - API-First Client
|
||||||
|
All database operations go through API endpoints, not direct SQLAlchemy.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
from decimal import Decimal
|
||||||
|
import aiohttp
|
||||||
|
import time
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramBotClient:
|
||||||
|
"""
|
||||||
|
Telegram Bot that communicates exclusively via API calls.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- User authentication via JWT tokens stored in Redis
|
||||||
|
- All operations through API (no direct DB access)
|
||||||
|
- Async HTTP requests with aiohttp
|
||||||
|
- Event listening via Redis Streams
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, bot_token: str, api_base_url: str, redis_client: redis.Redis):
|
||||||
|
self.bot = Bot(token=bot_token)
|
||||||
|
self.dp = Dispatcher()
|
||||||
|
self.api_base_url = api_base_url
|
||||||
|
self.redis_client = redis_client
|
||||||
|
self.session: Optional[aiohttp.ClientSession] = None
|
||||||
|
|
||||||
|
# Register handlers
|
||||||
|
self._setup_handlers()
|
||||||
|
|
||||||
|
def _setup_handlers(self):
|
||||||
|
"""Register message handlers"""
|
||||||
|
self.dp.message.register(self.cmd_start, Command("start"))
|
||||||
|
self.dp.message.register(self.cmd_help, Command("help"))
|
||||||
|
self.dp.message.register(self.cmd_balance, Command("balance"))
|
||||||
|
self.dp.message.register(self.cmd_add_transaction, Command("add"))
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
"""Start bot polling"""
|
||||||
|
self.session = aiohttp.ClientSession()
|
||||||
|
logger.info("Telegram bot started")
|
||||||
|
|
||||||
|
# Start polling
|
||||||
|
try:
|
||||||
|
await self.dp.start_polling(self.bot)
|
||||||
|
finally:
|
||||||
|
await self.session.close()
|
||||||
|
|
||||||
|
# ========== Handler: /start (Binding) ==========
|
||||||
|
async def cmd_start(self, message: Message):
|
||||||
|
"""
|
||||||
|
/start - Begin Telegram binding process.
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|
# Check if already bound
|
||||||
|
jwt_key = f"chat_id:{chat_id}:jwt"
|
||||||
|
existing_token = self.redis_client.get(jwt_key)
|
||||||
|
|
||||||
|
if existing_token:
|
||||||
|
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:
|
||||||
|
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_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
|
||||||
|
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\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}", 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:**
|
||||||
|
- 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 from Redis
|
||||||
|
jwt_token = self._get_user_jwt(chat_id)
|
||||||
|
|
||||||
|
if not jwt_token:
|
||||||
|
# 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/accounts?family_id=1
|
||||||
|
accounts_response = await self._api_call(
|
||||||
|
method="GET",
|
||||||
|
endpoint="/api/v1/accounts",
|
||||||
|
jwt_token=jwt_token,
|
||||||
|
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 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 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):
|
||||||
|
"""
|
||||||
|
/add - Create new transaction (interactive).
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
1. Ask for amount
|
||||||
|
2. Ask for category
|
||||||
|
3. Ask for wallet (from/to)
|
||||||
|
4. Create transaction via API
|
||||||
|
"""
|
||||||
|
|
||||||
|
chat_id = message.chat.id
|
||||||
|
jwt_token = self._get_user_jwt(chat_id)
|
||||||
|
|
||||||
|
if not jwt_token:
|
||||||
|
await message.answer("❌ Not connected. Use /start first.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Store conversation state in Redis
|
||||||
|
state_key = f"chat_id:{chat_id}:state"
|
||||||
|
self.redis_client.setex(state_key, 300, json.dumps({
|
||||||
|
"action": "add_transaction",
|
||||||
|
"step": 1, # Waiting for amount
|
||||||
|
}))
|
||||||
|
|
||||||
|
await message.answer("💵 How much?\n\nEnter amount (e.g., 50.00)")
|
||||||
|
|
||||||
|
async def handle_transaction_input(self, message: Message, state: Dict[str, Any]):
|
||||||
|
"""Handle transaction creation in steps"""
|
||||||
|
chat_id = message.chat.id
|
||||||
|
jwt_token = self._get_user_jwt(chat_id)
|
||||||
|
|
||||||
|
step = state.get("step", 1)
|
||||||
|
|
||||||
|
if step == 1:
|
||||||
|
# Amount entered
|
||||||
|
try:
|
||||||
|
amount = Decimal(message.text)
|
||||||
|
except:
|
||||||
|
await message.answer("❌ Invalid amount. Try again.")
|
||||||
|
return
|
||||||
|
|
||||||
|
state["amount"] = float(amount)
|
||||||
|
state["step"] = 2
|
||||||
|
self.redis_client.setex(f"chat_id:{chat_id}:state", 300, json.dumps(state))
|
||||||
|
|
||||||
|
await message.answer("📂 Which category?\n\n/food /transport /other")
|
||||||
|
|
||||||
|
elif step == 2:
|
||||||
|
# Category selected
|
||||||
|
state["category"] = message.text
|
||||||
|
state["step"] = 3
|
||||||
|
self.redis_client.setex(f"chat_id:{chat_id}:state", 300, json.dumps(state))
|
||||||
|
|
||||||
|
await message.answer("💬 Any notes?\n\n(or /skip)")
|
||||||
|
|
||||||
|
elif step == 3:
|
||||||
|
# Notes entered (or skipped)
|
||||||
|
state["notes"] = message.text if message.text != "/skip" else ""
|
||||||
|
|
||||||
|
# Create transaction via API
|
||||||
|
try:
|
||||||
|
result = await self._api_call(
|
||||||
|
method="POST",
|
||||||
|
endpoint="/api/v1/transactions",
|
||||||
|
jwt_token=jwt_token,
|
||||||
|
data={
|
||||||
|
"family_id": 1,
|
||||||
|
"from_wallet_id": 10,
|
||||||
|
"amount": state["amount"],
|
||||||
|
"category_id": 5, # TODO: Map category
|
||||||
|
"description": state["category"],
|
||||||
|
"notes": state["notes"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
tx_id = result.get("id")
|
||||||
|
await message.answer(f"✅ Transaction #{tx_id} created!")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Transaction creation error: {e}")
|
||||||
|
await message.answer("❌ Creation failed. Try again.")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Clean up state
|
||||||
|
self.redis_client.delete(f"chat_id:{chat_id}:state")
|
||||||
|
|
||||||
|
# ========== Handler: /help ==========
|
||||||
|
async def cmd_help(self, message: Message):
|
||||||
|
"""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:**
|
||||||
|
|
||||||
|
🔗 **Account**
|
||||||
|
/start - Re-bind account (if needed)
|
||||||
|
/balance - Show all account balances
|
||||||
|
/settings - Account settings
|
||||||
|
|
||||||
|
💰 **Transactions**
|
||||||
|
/add - Add new transaction
|
||||||
|
/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 ==========
|
||||||
|
async def _api_call(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
endpoint: str,
|
||||||
|
data: Dict = None,
|
||||||
|
params: Dict = None,
|
||||||
|
jwt_token: Optional[str] = None,
|
||||||
|
use_jwt: bool = True,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Make HTTP request to API with proper authentication headers.
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
- Authorization: Bearer <jwt_token> (if use_jwt=True)
|
||||||
|
- X-Client-Id: telegram_bot
|
||||||
|
- 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 and requested
|
||||||
|
if use_jwt and jwt_token:
|
||||||
|
headers["Authorization"] = f"Bearer {jwt_token}"
|
||||||
|
|
||||||
|
# Add HMAC signature for integrity verification
|
||||||
|
timestamp = int(time.time())
|
||||||
|
headers["X-Timestamp"] = str(timestamp)
|
||||||
|
|
||||||
|
signature = hmac_manager.create_signature(
|
||||||
|
method=method,
|
||||||
|
endpoint=endpoint,
|
||||||
|
timestamp=timestamp,
|
||||||
|
body=data,
|
||||||
|
)
|
||||||
|
headers["X-Signature"] = signature
|
||||||
|
|
||||||
|
# Make request
|
||||||
|
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"""
|
||||||
|
jwt_key = f"chat_id:{chat_id}:jwt"
|
||||||
|
token = self.redis_client.get(jwt_key)
|
||||||
|
return token.decode() if token else None
|
||||||
|
|
||||||
|
async def send_notification(self, chat_id: int, message: str):
|
||||||
|
"""Send notification to user"""
|
||||||
|
try:
|
||||||
|
await self.bot.send_message(chat_id=chat_id, text=message)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send notification to {chat_id}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# Bot factory
|
||||||
|
async def create_telegram_bot(
|
||||||
|
bot_token: str,
|
||||||
|
api_base_url: str,
|
||||||
|
redis_client: redis.Redis,
|
||||||
|
) -> TelegramBotClient:
|
||||||
|
"""Create and start Telegram bot"""
|
||||||
|
bot = TelegramBotClient(bot_token, api_base_url, redis_client)
|
||||||
|
return bot
|
||||||
480
.history/app/bot/client_20251210221338.py
Normal file
480
.history/app/bot/client_20251210221338.py
Normal file
@@ -0,0 +1,480 @@
|
|||||||
|
"""
|
||||||
|
Telegram Bot - API-First Client
|
||||||
|
All database operations go through API endpoints, not direct SQLAlchemy.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
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
|
||||||
|
from app.security.hmac_manager import hmac_manager
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramBotClient:
|
||||||
|
"""
|
||||||
|
Telegram Bot that communicates exclusively via API calls.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- User authentication via JWT tokens stored in Redis
|
||||||
|
- All operations through API (no direct DB access)
|
||||||
|
- Async HTTP requests with aiohttp
|
||||||
|
- Event listening via Redis Streams
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, bot_token: str, api_base_url: str, redis_client: redis.Redis):
|
||||||
|
self.bot = Bot(token=bot_token)
|
||||||
|
self.dp = Dispatcher()
|
||||||
|
self.api_base_url = api_base_url
|
||||||
|
self.redis_client = redis_client
|
||||||
|
self.session: Optional[aiohttp.ClientSession] = None
|
||||||
|
|
||||||
|
# Register handlers
|
||||||
|
self._setup_handlers()
|
||||||
|
|
||||||
|
def _setup_handlers(self):
|
||||||
|
"""Register message handlers"""
|
||||||
|
self.dp.message.register(self.cmd_start, Command("start"))
|
||||||
|
self.dp.message.register(self.cmd_help, Command("help"))
|
||||||
|
self.dp.message.register(self.cmd_balance, Command("balance"))
|
||||||
|
self.dp.message.register(self.cmd_add_transaction, Command("add"))
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
"""Start bot polling"""
|
||||||
|
self.session = aiohttp.ClientSession()
|
||||||
|
logger.info("Telegram bot started")
|
||||||
|
|
||||||
|
# Start polling
|
||||||
|
try:
|
||||||
|
await self.dp.start_polling(self.bot)
|
||||||
|
finally:
|
||||||
|
await self.session.close()
|
||||||
|
|
||||||
|
# ========== Handler: /start (Binding) ==========
|
||||||
|
async def cmd_start(self, message: Message):
|
||||||
|
"""
|
||||||
|
/start - Begin Telegram binding process.
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|
# Check if already bound
|
||||||
|
jwt_key = f"chat_id:{chat_id}:jwt"
|
||||||
|
existing_token = self.redis_client.get(jwt_key)
|
||||||
|
|
||||||
|
if existing_token:
|
||||||
|
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:
|
||||||
|
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_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
|
||||||
|
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\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}", 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:**
|
||||||
|
- 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 from Redis
|
||||||
|
jwt_token = self._get_user_jwt(chat_id)
|
||||||
|
|
||||||
|
if not jwt_token:
|
||||||
|
# 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/accounts?family_id=1
|
||||||
|
accounts_response = await self._api_call(
|
||||||
|
method="GET",
|
||||||
|
endpoint="/api/v1/accounts",
|
||||||
|
jwt_token=jwt_token,
|
||||||
|
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 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 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):
|
||||||
|
"""
|
||||||
|
/add - Create new transaction (interactive).
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
1. Ask for amount
|
||||||
|
2. Ask for category
|
||||||
|
3. Ask for wallet (from/to)
|
||||||
|
4. Create transaction via API
|
||||||
|
"""
|
||||||
|
|
||||||
|
chat_id = message.chat.id
|
||||||
|
jwt_token = self._get_user_jwt(chat_id)
|
||||||
|
|
||||||
|
if not jwt_token:
|
||||||
|
await message.answer("❌ Not connected. Use /start first.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Store conversation state in Redis
|
||||||
|
state_key = f"chat_id:{chat_id}:state"
|
||||||
|
self.redis_client.setex(state_key, 300, json.dumps({
|
||||||
|
"action": "add_transaction",
|
||||||
|
"step": 1, # Waiting for amount
|
||||||
|
}))
|
||||||
|
|
||||||
|
await message.answer("💵 How much?\n\nEnter amount (e.g., 50.00)")
|
||||||
|
|
||||||
|
async def handle_transaction_input(self, message: Message, state: Dict[str, Any]):
|
||||||
|
"""Handle transaction creation in steps"""
|
||||||
|
chat_id = message.chat.id
|
||||||
|
jwt_token = self._get_user_jwt(chat_id)
|
||||||
|
|
||||||
|
step = state.get("step", 1)
|
||||||
|
|
||||||
|
if step == 1:
|
||||||
|
# Amount entered
|
||||||
|
try:
|
||||||
|
amount = Decimal(message.text)
|
||||||
|
except:
|
||||||
|
await message.answer("❌ Invalid amount. Try again.")
|
||||||
|
return
|
||||||
|
|
||||||
|
state["amount"] = float(amount)
|
||||||
|
state["step"] = 2
|
||||||
|
self.redis_client.setex(f"chat_id:{chat_id}:state", 300, json.dumps(state))
|
||||||
|
|
||||||
|
await message.answer("📂 Which category?\n\n/food /transport /other")
|
||||||
|
|
||||||
|
elif step == 2:
|
||||||
|
# Category selected
|
||||||
|
state["category"] = message.text
|
||||||
|
state["step"] = 3
|
||||||
|
self.redis_client.setex(f"chat_id:{chat_id}:state", 300, json.dumps(state))
|
||||||
|
|
||||||
|
await message.answer("💬 Any notes?\n\n(or /skip)")
|
||||||
|
|
||||||
|
elif step == 3:
|
||||||
|
# Notes entered (or skipped)
|
||||||
|
state["notes"] = message.text if message.text != "/skip" else ""
|
||||||
|
|
||||||
|
# Create transaction via API
|
||||||
|
try:
|
||||||
|
result = await self._api_call(
|
||||||
|
method="POST",
|
||||||
|
endpoint="/api/v1/transactions",
|
||||||
|
jwt_token=jwt_token,
|
||||||
|
data={
|
||||||
|
"family_id": 1,
|
||||||
|
"from_wallet_id": 10,
|
||||||
|
"amount": state["amount"],
|
||||||
|
"category_id": 5, # TODO: Map category
|
||||||
|
"description": state["category"],
|
||||||
|
"notes": state["notes"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
tx_id = result.get("id")
|
||||||
|
await message.answer(f"✅ Transaction #{tx_id} created!")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Transaction creation error: {e}")
|
||||||
|
await message.answer("❌ Creation failed. Try again.")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Clean up state
|
||||||
|
self.redis_client.delete(f"chat_id:{chat_id}:state")
|
||||||
|
|
||||||
|
# ========== Handler: /help ==========
|
||||||
|
async def cmd_help(self, message: Message):
|
||||||
|
"""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:**
|
||||||
|
|
||||||
|
🔗 **Account**
|
||||||
|
/start - Re-bind account (if needed)
|
||||||
|
/balance - Show all account balances
|
||||||
|
/settings - Account settings
|
||||||
|
|
||||||
|
💰 **Transactions**
|
||||||
|
/add - Add new transaction
|
||||||
|
/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 ==========
|
||||||
|
async def _api_call(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
endpoint: str,
|
||||||
|
data: Dict = None,
|
||||||
|
params: Dict = None,
|
||||||
|
jwt_token: Optional[str] = None,
|
||||||
|
use_jwt: bool = True,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Make HTTP request to API with proper authentication headers.
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
- Authorization: Bearer <jwt_token> (if use_jwt=True)
|
||||||
|
- X-Client-Id: telegram_bot
|
||||||
|
- 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 and requested
|
||||||
|
if use_jwt and jwt_token:
|
||||||
|
headers["Authorization"] = f"Bearer {jwt_token}"
|
||||||
|
|
||||||
|
# Add HMAC signature for integrity verification
|
||||||
|
timestamp = int(time.time())
|
||||||
|
headers["X-Timestamp"] = str(timestamp)
|
||||||
|
|
||||||
|
signature = hmac_manager.create_signature(
|
||||||
|
method=method,
|
||||||
|
endpoint=endpoint,
|
||||||
|
timestamp=timestamp,
|
||||||
|
body=data,
|
||||||
|
)
|
||||||
|
headers["X-Signature"] = signature
|
||||||
|
|
||||||
|
# Make request
|
||||||
|
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"""
|
||||||
|
jwt_key = f"chat_id:{chat_id}:jwt"
|
||||||
|
token = self.redis_client.get(jwt_key)
|
||||||
|
return token.decode() if token else None
|
||||||
|
|
||||||
|
async def send_notification(self, chat_id: int, message: str):
|
||||||
|
"""Send notification to user"""
|
||||||
|
try:
|
||||||
|
await self.bot.send_message(chat_id=chat_id, text=message)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send notification to {chat_id}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# Bot factory
|
||||||
|
async def create_telegram_bot(
|
||||||
|
bot_token: str,
|
||||||
|
api_base_url: str,
|
||||||
|
redis_client: redis.Redis,
|
||||||
|
) -> TelegramBotClient:
|
||||||
|
"""Create and start Telegram bot"""
|
||||||
|
bot = TelegramBotClient(bot_token, api_base_url, redis_client)
|
||||||
|
return bot
|
||||||
480
.history/app/bot/client_20251210221526.py
Normal file
480
.history/app/bot/client_20251210221526.py
Normal file
@@ -0,0 +1,480 @@
|
|||||||
|
"""
|
||||||
|
Telegram Bot - API-First Client
|
||||||
|
All database operations go through API endpoints, not direct SQLAlchemy.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
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
|
||||||
|
from app.security.hmac_manager import hmac_manager
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramBotClient:
|
||||||
|
"""
|
||||||
|
Telegram Bot that communicates exclusively via API calls.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- User authentication via JWT tokens stored in Redis
|
||||||
|
- All operations through API (no direct DB access)
|
||||||
|
- Async HTTP requests with aiohttp
|
||||||
|
- Event listening via Redis Streams
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, bot_token: str, api_base_url: str, redis_client: redis.Redis):
|
||||||
|
self.bot = Bot(token=bot_token)
|
||||||
|
self.dp = Dispatcher()
|
||||||
|
self.api_base_url = api_base_url
|
||||||
|
self.redis_client = redis_client
|
||||||
|
self.session: Optional[aiohttp.ClientSession] = None
|
||||||
|
|
||||||
|
# Register handlers
|
||||||
|
self._setup_handlers()
|
||||||
|
|
||||||
|
def _setup_handlers(self):
|
||||||
|
"""Register message handlers"""
|
||||||
|
self.dp.message.register(self.cmd_start, Command("start"))
|
||||||
|
self.dp.message.register(self.cmd_help, Command("help"))
|
||||||
|
self.dp.message.register(self.cmd_balance, Command("balance"))
|
||||||
|
self.dp.message.register(self.cmd_add_transaction, Command("add"))
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
"""Start bot polling"""
|
||||||
|
self.session = aiohttp.ClientSession()
|
||||||
|
logger.info("Telegram bot started")
|
||||||
|
|
||||||
|
# Start polling
|
||||||
|
try:
|
||||||
|
await self.dp.start_polling(self.bot)
|
||||||
|
finally:
|
||||||
|
await self.session.close()
|
||||||
|
|
||||||
|
# ========== Handler: /start (Binding) ==========
|
||||||
|
async def cmd_start(self, message: Message):
|
||||||
|
"""
|
||||||
|
/start - Begin Telegram binding process.
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|
# Check if already bound
|
||||||
|
jwt_key = f"chat_id:{chat_id}:jwt"
|
||||||
|
existing_token = self.redis_client.get(jwt_key)
|
||||||
|
|
||||||
|
if existing_token:
|
||||||
|
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:
|
||||||
|
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_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
|
||||||
|
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\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}", 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:**
|
||||||
|
- 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 from Redis
|
||||||
|
jwt_token = self._get_user_jwt(chat_id)
|
||||||
|
|
||||||
|
if not jwt_token:
|
||||||
|
# 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/accounts?family_id=1
|
||||||
|
accounts_response = await self._api_call(
|
||||||
|
method="GET",
|
||||||
|
endpoint="/api/v1/accounts",
|
||||||
|
jwt_token=jwt_token,
|
||||||
|
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 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 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):
|
||||||
|
"""
|
||||||
|
/add - Create new transaction (interactive).
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
1. Ask for amount
|
||||||
|
2. Ask for category
|
||||||
|
3. Ask for wallet (from/to)
|
||||||
|
4. Create transaction via API
|
||||||
|
"""
|
||||||
|
|
||||||
|
chat_id = message.chat.id
|
||||||
|
jwt_token = self._get_user_jwt(chat_id)
|
||||||
|
|
||||||
|
if not jwt_token:
|
||||||
|
await message.answer("❌ Not connected. Use /start first.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Store conversation state in Redis
|
||||||
|
state_key = f"chat_id:{chat_id}:state"
|
||||||
|
self.redis_client.setex(state_key, 300, json.dumps({
|
||||||
|
"action": "add_transaction",
|
||||||
|
"step": 1, # Waiting for amount
|
||||||
|
}))
|
||||||
|
|
||||||
|
await message.answer("💵 How much?\n\nEnter amount (e.g., 50.00)")
|
||||||
|
|
||||||
|
async def handle_transaction_input(self, message: Message, state: Dict[str, Any]):
|
||||||
|
"""Handle transaction creation in steps"""
|
||||||
|
chat_id = message.chat.id
|
||||||
|
jwt_token = self._get_user_jwt(chat_id)
|
||||||
|
|
||||||
|
step = state.get("step", 1)
|
||||||
|
|
||||||
|
if step == 1:
|
||||||
|
# Amount entered
|
||||||
|
try:
|
||||||
|
amount = Decimal(message.text)
|
||||||
|
except:
|
||||||
|
await message.answer("❌ Invalid amount. Try again.")
|
||||||
|
return
|
||||||
|
|
||||||
|
state["amount"] = float(amount)
|
||||||
|
state["step"] = 2
|
||||||
|
self.redis_client.setex(f"chat_id:{chat_id}:state", 300, json.dumps(state))
|
||||||
|
|
||||||
|
await message.answer("📂 Which category?\n\n/food /transport /other")
|
||||||
|
|
||||||
|
elif step == 2:
|
||||||
|
# Category selected
|
||||||
|
state["category"] = message.text
|
||||||
|
state["step"] = 3
|
||||||
|
self.redis_client.setex(f"chat_id:{chat_id}:state", 300, json.dumps(state))
|
||||||
|
|
||||||
|
await message.answer("💬 Any notes?\n\n(or /skip)")
|
||||||
|
|
||||||
|
elif step == 3:
|
||||||
|
# Notes entered (or skipped)
|
||||||
|
state["notes"] = message.text if message.text != "/skip" else ""
|
||||||
|
|
||||||
|
# Create transaction via API
|
||||||
|
try:
|
||||||
|
result = await self._api_call(
|
||||||
|
method="POST",
|
||||||
|
endpoint="/api/v1/transactions",
|
||||||
|
jwt_token=jwt_token,
|
||||||
|
data={
|
||||||
|
"family_id": 1,
|
||||||
|
"from_wallet_id": 10,
|
||||||
|
"amount": state["amount"],
|
||||||
|
"category_id": 5, # TODO: Map category
|
||||||
|
"description": state["category"],
|
||||||
|
"notes": state["notes"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
tx_id = result.get("id")
|
||||||
|
await message.answer(f"✅ Transaction #{tx_id} created!")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Transaction creation error: {e}")
|
||||||
|
await message.answer("❌ Creation failed. Try again.")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Clean up state
|
||||||
|
self.redis_client.delete(f"chat_id:{chat_id}:state")
|
||||||
|
|
||||||
|
# ========== Handler: /help ==========
|
||||||
|
async def cmd_help(self, message: Message):
|
||||||
|
"""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:**
|
||||||
|
|
||||||
|
🔗 **Account**
|
||||||
|
/start - Re-bind account (if needed)
|
||||||
|
/balance - Show all account balances
|
||||||
|
/settings - Account settings
|
||||||
|
|
||||||
|
💰 **Transactions**
|
||||||
|
/add - Add new transaction
|
||||||
|
/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 ==========
|
||||||
|
async def _api_call(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
endpoint: str,
|
||||||
|
data: Dict = None,
|
||||||
|
params: Dict = None,
|
||||||
|
jwt_token: Optional[str] = None,
|
||||||
|
use_jwt: bool = True,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Make HTTP request to API with proper authentication headers.
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
- Authorization: Bearer <jwt_token> (if use_jwt=True)
|
||||||
|
- X-Client-Id: telegram_bot
|
||||||
|
- 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 and requested
|
||||||
|
if use_jwt and jwt_token:
|
||||||
|
headers["Authorization"] = f"Bearer {jwt_token}"
|
||||||
|
|
||||||
|
# Add HMAC signature for integrity verification
|
||||||
|
timestamp = int(time.time())
|
||||||
|
headers["X-Timestamp"] = str(timestamp)
|
||||||
|
|
||||||
|
signature = hmac_manager.create_signature(
|
||||||
|
method=method,
|
||||||
|
endpoint=endpoint,
|
||||||
|
timestamp=timestamp,
|
||||||
|
body=data,
|
||||||
|
)
|
||||||
|
headers["X-Signature"] = signature
|
||||||
|
|
||||||
|
# Make request
|
||||||
|
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"""
|
||||||
|
jwt_key = f"chat_id:{chat_id}:jwt"
|
||||||
|
token = self.redis_client.get(jwt_key)
|
||||||
|
return token.decode() if token else None
|
||||||
|
|
||||||
|
async def send_notification(self, chat_id: int, message: str):
|
||||||
|
"""Send notification to user"""
|
||||||
|
try:
|
||||||
|
await self.bot.send_message(chat_id=chat_id, text=message)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send notification to {chat_id}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# Bot factory
|
||||||
|
async def create_telegram_bot(
|
||||||
|
bot_token: str,
|
||||||
|
api_base_url: str,
|
||||||
|
redis_client: redis.Redis,
|
||||||
|
) -> TelegramBotClient:
|
||||||
|
"""Create and start Telegram bot"""
|
||||||
|
bot = TelegramBotClient(bot_token, api_base_url, redis_client)
|
||||||
|
return bot
|
||||||
316
.history/app/security/middleware_20251210221744.py
Normal file
316
.history/app/security/middleware_20251210221744.py
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
"""
|
||||||
|
FastAPI Middleware Stack - Authentication, Authorization, and Security
|
||||||
|
"""
|
||||||
|
from fastapi import FastAPI, Request, HTTPException, status
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Optional, Callable, Any
|
||||||
|
from datetime import datetime
|
||||||
|
import redis
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
|
||||||
|
from app.security.jwt_manager import jwt_manager, TokenPayload
|
||||||
|
from app.security.hmac_manager import hmac_manager
|
||||||
|
from app.security.rbac import RBACEngine, UserContext, MemberRole, Permission
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""Add security headers to all responses"""
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next: Callable) -> Any:
|
||||||
|
response = await call_next(request)
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||||
|
response.headers["X-Frame-Options"] = "DENY"
|
||||||
|
response.headers["X-XSS-Protection"] = "1; mode=block"
|
||||||
|
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
||||||
|
response.headers["Content-Security-Policy"] = "default-src 'self'"
|
||||||
|
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""Rate limiting using Redis"""
|
||||||
|
|
||||||
|
def __init__(self, app: FastAPI, redis_client: redis.Redis):
|
||||||
|
super().__init__(app)
|
||||||
|
self.redis_client = redis_client
|
||||||
|
self.rate_limit_requests = 100 # requests
|
||||||
|
self.rate_limit_window = 60 # seconds
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next: Callable) -> Any:
|
||||||
|
# Skip rate limiting for health checks
|
||||||
|
if request.url.path == "/health":
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
# Get client IP
|
||||||
|
client_ip = request.client.host
|
||||||
|
|
||||||
|
# Rate limit key
|
||||||
|
rate_key = f"rate_limit:{client_ip}"
|
||||||
|
|
||||||
|
# Check rate limit
|
||||||
|
try:
|
||||||
|
current = self.redis_client.get(rate_key)
|
||||||
|
if current and int(current) >= self.rate_limit_requests:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=429,
|
||||||
|
content={"detail": "Rate limit exceeded"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Increment counter
|
||||||
|
pipe = self.redis_client.pipeline()
|
||||||
|
pipe.incr(rate_key)
|
||||||
|
pipe.expire(rate_key, self.rate_limit_window)
|
||||||
|
pipe.execute()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Rate limiting error: {e}")
|
||||||
|
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
|
class HMACVerificationMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""HMAC signature verification and anti-replay protection"""
|
||||||
|
|
||||||
|
def __init__(self, app: FastAPI, redis_client: redis.Redis):
|
||||||
|
super().__init__(app)
|
||||||
|
self.redis_client = redis_client
|
||||||
|
hmac_manager.redis_client = redis_client
|
||||||
|
|
||||||
|
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",
|
||||||
|
"/api/v1/auth/telegram/register",
|
||||||
|
"/api/v1/auth/telegram/authenticate",
|
||||||
|
]
|
||||||
|
if request.url.path in public_paths:
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
# Extract HMAC headers
|
||||||
|
signature = request.headers.get("X-Signature")
|
||||||
|
timestamp = request.headers.get("X-Timestamp")
|
||||||
|
client_id = request.headers.get("X-Client-Id", "unknown")
|
||||||
|
|
||||||
|
# HMAC verification is optional in MVP (configurable)
|
||||||
|
if settings.require_hmac_verification:
|
||||||
|
if not signature or not timestamp:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=400,
|
||||||
|
content={"detail": "Missing HMAC headers"}
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
timestamp_int = int(timestamp)
|
||||||
|
except ValueError:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=400,
|
||||||
|
content={"detail": "Invalid timestamp format"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Read body for signature verification
|
||||||
|
body = await request.body()
|
||||||
|
body_dict = {}
|
||||||
|
if body:
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
body_dict = json.loads(body)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Verify HMAC
|
||||||
|
# Get client secret (hardcoded for MVP, should be from DB)
|
||||||
|
client_secret = settings.hmac_secret_key
|
||||||
|
|
||||||
|
is_valid, error_msg = hmac_manager.verify_signature(
|
||||||
|
method=request.method,
|
||||||
|
endpoint=request.url.path,
|
||||||
|
timestamp=timestamp_int,
|
||||||
|
signature=signature,
|
||||||
|
body=body_dict,
|
||||||
|
client_secret=client_secret,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not is_valid:
|
||||||
|
logger.warning(f"HMAC verification failed: {error_msg} (client: {client_id})")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=401,
|
||||||
|
content={"detail": f"HMAC verification failed: {error_msg}"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store in request state for logging
|
||||||
|
request.state.client_id = client_id
|
||||||
|
request.state.timestamp = timestamp
|
||||||
|
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
|
class JWTAuthenticationMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""JWT token verification and extraction"""
|
||||||
|
|
||||||
|
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"]
|
||||||
|
if request.url.path in public_paths:
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
# Extract token from Authorization header
|
||||||
|
auth_header = request.headers.get("Authorization")
|
||||||
|
if not auth_header:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=401,
|
||||||
|
content={"detail": "Missing Authorization header"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse "Bearer <token>"
|
||||||
|
try:
|
||||||
|
scheme, token = auth_header.split()
|
||||||
|
if scheme.lower() != "bearer":
|
||||||
|
raise ValueError("Invalid auth scheme")
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=401,
|
||||||
|
content={"detail": "Invalid Authorization header format"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify JWT
|
||||||
|
try:
|
||||||
|
token_payload = jwt_manager.verify_token(token)
|
||||||
|
except ValueError as e:
|
||||||
|
logger.warning(f"JWT verification failed: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=401,
|
||||||
|
content={"detail": "Invalid or expired token"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store in request state
|
||||||
|
request.state.user_id = token_payload.sub
|
||||||
|
request.state.token_type = token_payload.type
|
||||||
|
request.state.device_id = token_payload.device_id
|
||||||
|
request.state.family_ids = token_payload.family_ids
|
||||||
|
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
|
class RBACMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""Role-Based Access Control enforcement"""
|
||||||
|
|
||||||
|
def __init__(self, app: FastAPI, db_session: Any):
|
||||||
|
super().__init__(app)
|
||||||
|
self.db_session = db_session
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next: Callable) -> Any:
|
||||||
|
# Skip RBAC for public endpoints
|
||||||
|
if request.url.path in ["/health", "/docs", "/openapi.json"]:
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
# Get user context from JWT
|
||||||
|
user_id = getattr(request.state, "user_id", None)
|
||||||
|
family_ids = getattr(request.state, "family_ids", [])
|
||||||
|
|
||||||
|
if not user_id:
|
||||||
|
# Already handled by JWTAuthenticationMiddleware
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
# Extract family_id from URL or body
|
||||||
|
family_id = self._extract_family_id(request)
|
||||||
|
|
||||||
|
if family_id and family_id not in family_ids:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=403,
|
||||||
|
content={"detail": "Access denied to this family"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Load user role (would need DB query in production)
|
||||||
|
# For MVP: Store in request state, resolved in endpoint handlers
|
||||||
|
request.state.family_id = family_id
|
||||||
|
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_family_id(request: Request) -> Optional[int]:
|
||||||
|
"""Extract family_id from URL or request body"""
|
||||||
|
# From URL path: /api/v1/families/{family_id}/...
|
||||||
|
if "{family_id}" in request.url.path:
|
||||||
|
# Parse from actual path
|
||||||
|
parts = request.url.path.split("/")
|
||||||
|
for i, part in enumerate(parts):
|
||||||
|
if part == "families" and i + 1 < len(parts):
|
||||||
|
try:
|
||||||
|
return int(parts[i + 1])
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""Log all requests and responses for audit"""
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next: Callable) -> Any:
|
||||||
|
# Start timer
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
# Get client info
|
||||||
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
|
user_id = getattr(request.state, "user_id", None)
|
||||||
|
|
||||||
|
# Process request
|
||||||
|
try:
|
||||||
|
response = await call_next(request)
|
||||||
|
response_time_ms = int((time.time() - start_time) * 1000)
|
||||||
|
|
||||||
|
# Log successful request
|
||||||
|
logger.info(
|
||||||
|
f"Endpoint={request.url.path} "
|
||||||
|
f"Method={request.method} "
|
||||||
|
f"Status={response.status_code} "
|
||||||
|
f"Time={response_time_ms}ms "
|
||||||
|
f"User={user_id} "
|
||||||
|
f"IP={client_ip}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add timing header
|
||||||
|
response.headers["X-Response-Time"] = str(response_time_ms)
|
||||||
|
|
||||||
|
return response
|
||||||
|
except Exception as e:
|
||||||
|
response_time_ms = int((time.time() - start_time) * 1000)
|
||||||
|
logger.error(
|
||||||
|
f"Request error - Endpoint={request.url.path} "
|
||||||
|
f"Error={str(e)} "
|
||||||
|
f"Time={response_time_ms}ms "
|
||||||
|
f"User={user_id} "
|
||||||
|
f"IP={client_ip}"
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def add_security_middleware(app: FastAPI, redis_client: redis.Redis, db_session: Any):
|
||||||
|
"""Register all security middleware in correct order"""
|
||||||
|
|
||||||
|
# Order matters! Process in reverse order of registration:
|
||||||
|
# 1. RequestLoggingMiddleware (innermost, executes last)
|
||||||
|
# 2. RBACMiddleware
|
||||||
|
# 3. JWTAuthenticationMiddleware
|
||||||
|
# 4. HMACVerificationMiddleware
|
||||||
|
# 5. RateLimitMiddleware
|
||||||
|
# 6. SecurityHeadersMiddleware (outermost, executes first)
|
||||||
|
|
||||||
|
app.add_middleware(RequestLoggingMiddleware)
|
||||||
|
app.add_middleware(RBACMiddleware, db_session=db_session)
|
||||||
|
app.add_middleware(JWTAuthenticationMiddleware)
|
||||||
|
app.add_middleware(HMACVerificationMiddleware, redis_client=redis_client)
|
||||||
|
app.add_middleware(RateLimitMiddleware, redis_client=redis_client)
|
||||||
|
app.add_middleware(SecurityHeadersMiddleware)
|
||||||
324
.history/app/security/middleware_20251210221754.py
Normal file
324
.history/app/security/middleware_20251210221754.py
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
"""
|
||||||
|
FastAPI Middleware Stack - Authentication, Authorization, and Security
|
||||||
|
"""
|
||||||
|
from fastapi import FastAPI, Request, HTTPException, status
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Optional, Callable, Any
|
||||||
|
from datetime import datetime
|
||||||
|
import redis
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
|
||||||
|
from app.security.jwt_manager import jwt_manager, TokenPayload
|
||||||
|
from app.security.hmac_manager import hmac_manager
|
||||||
|
from app.security.rbac import RBACEngine, UserContext, MemberRole, Permission
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""Add security headers to all responses"""
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next: Callable) -> Any:
|
||||||
|
response = await call_next(request)
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||||
|
response.headers["X-Frame-Options"] = "DENY"
|
||||||
|
response.headers["X-XSS-Protection"] = "1; mode=block"
|
||||||
|
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
||||||
|
response.headers["Content-Security-Policy"] = "default-src 'self'"
|
||||||
|
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""Rate limiting using Redis"""
|
||||||
|
|
||||||
|
def __init__(self, app: FastAPI, redis_client: redis.Redis):
|
||||||
|
super().__init__(app)
|
||||||
|
self.redis_client = redis_client
|
||||||
|
self.rate_limit_requests = 100 # requests
|
||||||
|
self.rate_limit_window = 60 # seconds
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next: Callable) -> Any:
|
||||||
|
# Skip rate limiting for health checks
|
||||||
|
if request.url.path == "/health":
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
# Get client IP
|
||||||
|
client_ip = request.client.host
|
||||||
|
|
||||||
|
# Rate limit key
|
||||||
|
rate_key = f"rate_limit:{client_ip}"
|
||||||
|
|
||||||
|
# Check rate limit
|
||||||
|
try:
|
||||||
|
current = self.redis_client.get(rate_key)
|
||||||
|
if current and int(current) >= self.rate_limit_requests:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=429,
|
||||||
|
content={"detail": "Rate limit exceeded"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Increment counter
|
||||||
|
pipe = self.redis_client.pipeline()
|
||||||
|
pipe.incr(rate_key)
|
||||||
|
pipe.expire(rate_key, self.rate_limit_window)
|
||||||
|
pipe.execute()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Rate limiting error: {e}")
|
||||||
|
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
|
class HMACVerificationMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""HMAC signature verification and anti-replay protection"""
|
||||||
|
|
||||||
|
def __init__(self, app: FastAPI, redis_client: redis.Redis):
|
||||||
|
super().__init__(app)
|
||||||
|
self.redis_client = redis_client
|
||||||
|
hmac_manager.redis_client = redis_client
|
||||||
|
|
||||||
|
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",
|
||||||
|
"/api/v1/auth/telegram/register",
|
||||||
|
"/api/v1/auth/telegram/authenticate",
|
||||||
|
]
|
||||||
|
if request.url.path in public_paths:
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
# Extract HMAC headers
|
||||||
|
signature = request.headers.get("X-Signature")
|
||||||
|
timestamp = request.headers.get("X-Timestamp")
|
||||||
|
client_id = request.headers.get("X-Client-Id", "unknown")
|
||||||
|
|
||||||
|
# HMAC verification is optional in MVP (configurable)
|
||||||
|
if settings.require_hmac_verification:
|
||||||
|
if not signature or not timestamp:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=400,
|
||||||
|
content={"detail": "Missing HMAC headers"}
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
timestamp_int = int(timestamp)
|
||||||
|
except ValueError:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=400,
|
||||||
|
content={"detail": "Invalid timestamp format"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Read body for signature verification
|
||||||
|
body = await request.body()
|
||||||
|
body_dict = {}
|
||||||
|
if body:
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
body_dict = json.loads(body)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Verify HMAC
|
||||||
|
# Get client secret (hardcoded for MVP, should be from DB)
|
||||||
|
client_secret = settings.hmac_secret_key
|
||||||
|
|
||||||
|
is_valid, error_msg = hmac_manager.verify_signature(
|
||||||
|
method=request.method,
|
||||||
|
endpoint=request.url.path,
|
||||||
|
timestamp=timestamp_int,
|
||||||
|
signature=signature,
|
||||||
|
body=body_dict,
|
||||||
|
client_secret=client_secret,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not is_valid:
|
||||||
|
logger.warning(f"HMAC verification failed: {error_msg} (client: {client_id})")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=401,
|
||||||
|
content={"detail": f"HMAC verification failed: {error_msg}"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store in request state for logging
|
||||||
|
request.state.client_id = client_id
|
||||||
|
request.state.timestamp = timestamp
|
||||||
|
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
|
class JWTAuthenticationMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""JWT token verification and extraction"""
|
||||||
|
|
||||||
|
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",
|
||||||
|
"/api/v1/auth/telegram/register",
|
||||||
|
"/api/v1/auth/telegram/authenticate",
|
||||||
|
]
|
||||||
|
if request.url.path in public_paths:
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
# Extract token from Authorization header
|
||||||
|
auth_header = request.headers.get("Authorization")
|
||||||
|
if not auth_header:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=401,
|
||||||
|
content={"detail": "Missing Authorization header"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse "Bearer <token>"
|
||||||
|
try:
|
||||||
|
scheme, token = auth_header.split()
|
||||||
|
if scheme.lower() != "bearer":
|
||||||
|
raise ValueError("Invalid auth scheme")
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=401,
|
||||||
|
content={"detail": "Invalid Authorization header format"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify JWT
|
||||||
|
try:
|
||||||
|
token_payload = jwt_manager.verify_token(token)
|
||||||
|
except ValueError as e:
|
||||||
|
logger.warning(f"JWT verification failed: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=401,
|
||||||
|
content={"detail": "Invalid or expired token"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store in request state
|
||||||
|
request.state.user_id = token_payload.sub
|
||||||
|
request.state.token_type = token_payload.type
|
||||||
|
request.state.device_id = token_payload.device_id
|
||||||
|
request.state.family_ids = token_payload.family_ids
|
||||||
|
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
|
class RBACMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""Role-Based Access Control enforcement"""
|
||||||
|
|
||||||
|
def __init__(self, app: FastAPI, db_session: Any):
|
||||||
|
super().__init__(app)
|
||||||
|
self.db_session = db_session
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next: Callable) -> Any:
|
||||||
|
# Skip RBAC for public endpoints
|
||||||
|
if request.url.path in ["/health", "/docs", "/openapi.json"]:
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
# Get user context from JWT
|
||||||
|
user_id = getattr(request.state, "user_id", None)
|
||||||
|
family_ids = getattr(request.state, "family_ids", [])
|
||||||
|
|
||||||
|
if not user_id:
|
||||||
|
# Already handled by JWTAuthenticationMiddleware
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
# Extract family_id from URL or body
|
||||||
|
family_id = self._extract_family_id(request)
|
||||||
|
|
||||||
|
if family_id and family_id not in family_ids:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=403,
|
||||||
|
content={"detail": "Access denied to this family"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Load user role (would need DB query in production)
|
||||||
|
# For MVP: Store in request state, resolved in endpoint handlers
|
||||||
|
request.state.family_id = family_id
|
||||||
|
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_family_id(request: Request) -> Optional[int]:
|
||||||
|
"""Extract family_id from URL or request body"""
|
||||||
|
# From URL path: /api/v1/families/{family_id}/...
|
||||||
|
if "{family_id}" in request.url.path:
|
||||||
|
# Parse from actual path
|
||||||
|
parts = request.url.path.split("/")
|
||||||
|
for i, part in enumerate(parts):
|
||||||
|
if part == "families" and i + 1 < len(parts):
|
||||||
|
try:
|
||||||
|
return int(parts[i + 1])
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""Log all requests and responses for audit"""
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next: Callable) -> Any:
|
||||||
|
# Start timer
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
# Get client info
|
||||||
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
|
user_id = getattr(request.state, "user_id", None)
|
||||||
|
|
||||||
|
# Process request
|
||||||
|
try:
|
||||||
|
response = await call_next(request)
|
||||||
|
response_time_ms = int((time.time() - start_time) * 1000)
|
||||||
|
|
||||||
|
# Log successful request
|
||||||
|
logger.info(
|
||||||
|
f"Endpoint={request.url.path} "
|
||||||
|
f"Method={request.method} "
|
||||||
|
f"Status={response.status_code} "
|
||||||
|
f"Time={response_time_ms}ms "
|
||||||
|
f"User={user_id} "
|
||||||
|
f"IP={client_ip}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add timing header
|
||||||
|
response.headers["X-Response-Time"] = str(response_time_ms)
|
||||||
|
|
||||||
|
return response
|
||||||
|
except Exception as e:
|
||||||
|
response_time_ms = int((time.time() - start_time) * 1000)
|
||||||
|
logger.error(
|
||||||
|
f"Request error - Endpoint={request.url.path} "
|
||||||
|
f"Error={str(e)} "
|
||||||
|
f"Time={response_time_ms}ms "
|
||||||
|
f"User={user_id} "
|
||||||
|
f"IP={client_ip}"
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def add_security_middleware(app: FastAPI, redis_client: redis.Redis, db_session: Any):
|
||||||
|
"""Register all security middleware in correct order"""
|
||||||
|
|
||||||
|
# Order matters! Process in reverse order of registration:
|
||||||
|
# 1. RequestLoggingMiddleware (innermost, executes last)
|
||||||
|
# 2. RBACMiddleware
|
||||||
|
# 3. JWTAuthenticationMiddleware
|
||||||
|
# 4. HMACVerificationMiddleware
|
||||||
|
# 5. RateLimitMiddleware
|
||||||
|
# 6. SecurityHeadersMiddleware (outermost, executes first)
|
||||||
|
|
||||||
|
app.add_middleware(RequestLoggingMiddleware)
|
||||||
|
app.add_middleware(RBACMiddleware, db_session=db_session)
|
||||||
|
app.add_middleware(JWTAuthenticationMiddleware)
|
||||||
|
app.add_middleware(HMACVerificationMiddleware, redis_client=redis_client)
|
||||||
|
app.add_middleware(RateLimitMiddleware, redis_client=redis_client)
|
||||||
|
app.add_middleware(SecurityHeadersMiddleware)
|
||||||
324
.history/app/security/middleware_20251210221758.py
Normal file
324
.history/app/security/middleware_20251210221758.py
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
"""
|
||||||
|
FastAPI Middleware Stack - Authentication, Authorization, and Security
|
||||||
|
"""
|
||||||
|
from fastapi import FastAPI, Request, HTTPException, status
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Optional, Callable, Any
|
||||||
|
from datetime import datetime
|
||||||
|
import redis
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
|
||||||
|
from app.security.jwt_manager import jwt_manager, TokenPayload
|
||||||
|
from app.security.hmac_manager import hmac_manager
|
||||||
|
from app.security.rbac import RBACEngine, UserContext, MemberRole, Permission
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""Add security headers to all responses"""
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next: Callable) -> Any:
|
||||||
|
response = await call_next(request)
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||||
|
response.headers["X-Frame-Options"] = "DENY"
|
||||||
|
response.headers["X-XSS-Protection"] = "1; mode=block"
|
||||||
|
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
||||||
|
response.headers["Content-Security-Policy"] = "default-src 'self'"
|
||||||
|
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""Rate limiting using Redis"""
|
||||||
|
|
||||||
|
def __init__(self, app: FastAPI, redis_client: redis.Redis):
|
||||||
|
super().__init__(app)
|
||||||
|
self.redis_client = redis_client
|
||||||
|
self.rate_limit_requests = 100 # requests
|
||||||
|
self.rate_limit_window = 60 # seconds
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next: Callable) -> Any:
|
||||||
|
# Skip rate limiting for health checks
|
||||||
|
if request.url.path == "/health":
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
# Get client IP
|
||||||
|
client_ip = request.client.host
|
||||||
|
|
||||||
|
# Rate limit key
|
||||||
|
rate_key = f"rate_limit:{client_ip}"
|
||||||
|
|
||||||
|
# Check rate limit
|
||||||
|
try:
|
||||||
|
current = self.redis_client.get(rate_key)
|
||||||
|
if current and int(current) >= self.rate_limit_requests:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=429,
|
||||||
|
content={"detail": "Rate limit exceeded"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Increment counter
|
||||||
|
pipe = self.redis_client.pipeline()
|
||||||
|
pipe.incr(rate_key)
|
||||||
|
pipe.expire(rate_key, self.rate_limit_window)
|
||||||
|
pipe.execute()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Rate limiting error: {e}")
|
||||||
|
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
|
class HMACVerificationMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""HMAC signature verification and anti-replay protection"""
|
||||||
|
|
||||||
|
def __init__(self, app: FastAPI, redis_client: redis.Redis):
|
||||||
|
super().__init__(app)
|
||||||
|
self.redis_client = redis_client
|
||||||
|
hmac_manager.redis_client = redis_client
|
||||||
|
|
||||||
|
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",
|
||||||
|
"/api/v1/auth/telegram/register",
|
||||||
|
"/api/v1/auth/telegram/authenticate",
|
||||||
|
]
|
||||||
|
if request.url.path in public_paths:
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
# Extract HMAC headers
|
||||||
|
signature = request.headers.get("X-Signature")
|
||||||
|
timestamp = request.headers.get("X-Timestamp")
|
||||||
|
client_id = request.headers.get("X-Client-Id", "unknown")
|
||||||
|
|
||||||
|
# HMAC verification is optional in MVP (configurable)
|
||||||
|
if settings.require_hmac_verification:
|
||||||
|
if not signature or not timestamp:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=400,
|
||||||
|
content={"detail": "Missing HMAC headers"}
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
timestamp_int = int(timestamp)
|
||||||
|
except ValueError:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=400,
|
||||||
|
content={"detail": "Invalid timestamp format"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Read body for signature verification
|
||||||
|
body = await request.body()
|
||||||
|
body_dict = {}
|
||||||
|
if body:
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
body_dict = json.loads(body)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Verify HMAC
|
||||||
|
# Get client secret (hardcoded for MVP, should be from DB)
|
||||||
|
client_secret = settings.hmac_secret_key
|
||||||
|
|
||||||
|
is_valid, error_msg = hmac_manager.verify_signature(
|
||||||
|
method=request.method,
|
||||||
|
endpoint=request.url.path,
|
||||||
|
timestamp=timestamp_int,
|
||||||
|
signature=signature,
|
||||||
|
body=body_dict,
|
||||||
|
client_secret=client_secret,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not is_valid:
|
||||||
|
logger.warning(f"HMAC verification failed: {error_msg} (client: {client_id})")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=401,
|
||||||
|
content={"detail": f"HMAC verification failed: {error_msg}"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store in request state for logging
|
||||||
|
request.state.client_id = client_id
|
||||||
|
request.state.timestamp = timestamp
|
||||||
|
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
|
class JWTAuthenticationMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""JWT token verification and extraction"""
|
||||||
|
|
||||||
|
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",
|
||||||
|
"/api/v1/auth/telegram/register",
|
||||||
|
"/api/v1/auth/telegram/authenticate",
|
||||||
|
]
|
||||||
|
if request.url.path in public_paths:
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
# Extract token from Authorization header
|
||||||
|
auth_header = request.headers.get("Authorization")
|
||||||
|
if not auth_header:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=401,
|
||||||
|
content={"detail": "Missing Authorization header"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse "Bearer <token>"
|
||||||
|
try:
|
||||||
|
scheme, token = auth_header.split()
|
||||||
|
if scheme.lower() != "bearer":
|
||||||
|
raise ValueError("Invalid auth scheme")
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=401,
|
||||||
|
content={"detail": "Invalid Authorization header format"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify JWT
|
||||||
|
try:
|
||||||
|
token_payload = jwt_manager.verify_token(token)
|
||||||
|
except ValueError as e:
|
||||||
|
logger.warning(f"JWT verification failed: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=401,
|
||||||
|
content={"detail": "Invalid or expired token"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store in request state
|
||||||
|
request.state.user_id = token_payload.sub
|
||||||
|
request.state.token_type = token_payload.type
|
||||||
|
request.state.device_id = token_payload.device_id
|
||||||
|
request.state.family_ids = token_payload.family_ids
|
||||||
|
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
|
class RBACMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""Role-Based Access Control enforcement"""
|
||||||
|
|
||||||
|
def __init__(self, app: FastAPI, db_session: Any):
|
||||||
|
super().__init__(app)
|
||||||
|
self.db_session = db_session
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next: Callable) -> Any:
|
||||||
|
# Skip RBAC for public endpoints
|
||||||
|
if request.url.path in ["/health", "/docs", "/openapi.json"]:
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
# Get user context from JWT
|
||||||
|
user_id = getattr(request.state, "user_id", None)
|
||||||
|
family_ids = getattr(request.state, "family_ids", [])
|
||||||
|
|
||||||
|
if not user_id:
|
||||||
|
# Already handled by JWTAuthenticationMiddleware
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
# Extract family_id from URL or body
|
||||||
|
family_id = self._extract_family_id(request)
|
||||||
|
|
||||||
|
if family_id and family_id not in family_ids:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=403,
|
||||||
|
content={"detail": "Access denied to this family"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Load user role (would need DB query in production)
|
||||||
|
# For MVP: Store in request state, resolved in endpoint handlers
|
||||||
|
request.state.family_id = family_id
|
||||||
|
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_family_id(request: Request) -> Optional[int]:
|
||||||
|
"""Extract family_id from URL or request body"""
|
||||||
|
# From URL path: /api/v1/families/{family_id}/...
|
||||||
|
if "{family_id}" in request.url.path:
|
||||||
|
# Parse from actual path
|
||||||
|
parts = request.url.path.split("/")
|
||||||
|
for i, part in enumerate(parts):
|
||||||
|
if part == "families" and i + 1 < len(parts):
|
||||||
|
try:
|
||||||
|
return int(parts[i + 1])
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""Log all requests and responses for audit"""
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next: Callable) -> Any:
|
||||||
|
# Start timer
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
# Get client info
|
||||||
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
|
user_id = getattr(request.state, "user_id", None)
|
||||||
|
|
||||||
|
# Process request
|
||||||
|
try:
|
||||||
|
response = await call_next(request)
|
||||||
|
response_time_ms = int((time.time() - start_time) * 1000)
|
||||||
|
|
||||||
|
# Log successful request
|
||||||
|
logger.info(
|
||||||
|
f"Endpoint={request.url.path} "
|
||||||
|
f"Method={request.method} "
|
||||||
|
f"Status={response.status_code} "
|
||||||
|
f"Time={response_time_ms}ms "
|
||||||
|
f"User={user_id} "
|
||||||
|
f"IP={client_ip}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add timing header
|
||||||
|
response.headers["X-Response-Time"] = str(response_time_ms)
|
||||||
|
|
||||||
|
return response
|
||||||
|
except Exception as e:
|
||||||
|
response_time_ms = int((time.time() - start_time) * 1000)
|
||||||
|
logger.error(
|
||||||
|
f"Request error - Endpoint={request.url.path} "
|
||||||
|
f"Error={str(e)} "
|
||||||
|
f"Time={response_time_ms}ms "
|
||||||
|
f"User={user_id} "
|
||||||
|
f"IP={client_ip}"
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def add_security_middleware(app: FastAPI, redis_client: redis.Redis, db_session: Any):
|
||||||
|
"""Register all security middleware in correct order"""
|
||||||
|
|
||||||
|
# Order matters! Process in reverse order of registration:
|
||||||
|
# 1. RequestLoggingMiddleware (innermost, executes last)
|
||||||
|
# 2. RBACMiddleware
|
||||||
|
# 3. JWTAuthenticationMiddleware
|
||||||
|
# 4. HMACVerificationMiddleware
|
||||||
|
# 5. RateLimitMiddleware
|
||||||
|
# 6. SecurityHeadersMiddleware (outermost, executes first)
|
||||||
|
|
||||||
|
app.add_middleware(RequestLoggingMiddleware)
|
||||||
|
app.add_middleware(RBACMiddleware, db_session=db_session)
|
||||||
|
app.add_middleware(JWTAuthenticationMiddleware)
|
||||||
|
app.add_middleware(HMACVerificationMiddleware, redis_client=redis_client)
|
||||||
|
app.add_middleware(RateLimitMiddleware, redis_client=redis_client)
|
||||||
|
app.add_middleware(SecurityHeadersMiddleware)
|
||||||
260
.history/app/services/auth_service_20251210221228.py
Normal file
260
.history/app/services/auth_service_20251210221228.py
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
"""
|
||||||
|
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.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.Redis(
|
||||||
|
host=settings.REDIS_HOST,
|
||||||
|
port=settings.REDIS_PORT,
|
||||||
|
db=settings.REDIS_DB,
|
||||||
|
decode_responses=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthService:
|
||||||
|
"""Handles user authentication, token management, and Telegram binding"""
|
||||||
|
|
||||||
|
TELEGRAM_BINDING_CODE_TTL = 600 # 10 minutes
|
||||||
|
BINDING_CODE_LENGTH = 24
|
||||||
|
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def create_telegram_binding_code(self, chat_id: int) -> str:
|
||||||
|
"""
|
||||||
|
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 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.
|
||||||
|
|
||||||
|
**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",
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 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"}
|
||||||
|
|
||||||
|
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"}
|
||||||
|
|
||||||
|
# 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()
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
# 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}"
|
||||||
|
)
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
# 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"Telegram user authenticated: chat_id={chat_id}, user_id={user.id}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"user_id": user.id,
|
||||||
|
"jwt_token": access_token,
|
||||||
|
"expires_at": (
|
||||||
|
datetime.utcnow() + timedelta(hours=24)
|
||||||
|
).isoformat(),
|
||||||
|
}
|
||||||
255
.history/app/services/auth_service_20251210221406.py
Normal file
255
.history/app/services/auth_service_20251210221406.py
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
"""
|
||||||
|
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.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, token management, and Telegram binding"""
|
||||||
|
|
||||||
|
TELEGRAM_BINDING_CODE_TTL = 600 # 10 minutes
|
||||||
|
BINDING_CODE_LENGTH = 24
|
||||||
|
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def create_telegram_binding_code(self, chat_id: int) -> str:
|
||||||
|
"""
|
||||||
|
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 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.
|
||||||
|
|
||||||
|
**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",
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 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"}
|
||||||
|
|
||||||
|
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"}
|
||||||
|
|
||||||
|
# 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()
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
# 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}"
|
||||||
|
)
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
# 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"Telegram user authenticated: chat_id={chat_id}, user_id={user.id}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"user_id": user.id,
|
||||||
|
"jwt_token": access_token,
|
||||||
|
"expires_at": (
|
||||||
|
datetime.utcnow() + timedelta(hours=24)
|
||||||
|
).isoformat(),
|
||||||
|
}
|
||||||
279
.history/app/services/auth_service_20251210221434.py
Normal file
279
.history/app/services/auth_service_20251210221434.py
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
"""
|
||||||
|
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.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, token management, and Telegram binding"""
|
||||||
|
|
||||||
|
TELEGRAM_BINDING_CODE_TTL = 600 # 10 minutes
|
||||||
|
BINDING_CODE_LENGTH = 24
|
||||||
|
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def create_telegram_binding_code(self, chat_id: int) -> str:
|
||||||
|
"""
|
||||||
|
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 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.
|
||||||
|
|
||||||
|
**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")
|
||||||
|
|
||||||
|
# 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"Telegram user authenticated: chat_id={chat_id}, user_id={user.id}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"user_id": user.id,
|
||||||
|
"jwt_token": access_token,
|
||||||
|
"expires_at": (
|
||||||
|
datetime.utcnow() + timedelta(hours=24)
|
||||||
|
).isoformat(),
|
||||||
|
}
|
||||||
279
.history/app/services/auth_service_20251210221526.py
Normal file
279
.history/app/services/auth_service_20251210221526.py
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
"""
|
||||||
|
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.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, token management, and Telegram binding"""
|
||||||
|
|
||||||
|
TELEGRAM_BINDING_CODE_TTL = 600 # 10 minutes
|
||||||
|
BINDING_CODE_LENGTH = 24
|
||||||
|
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def create_telegram_binding_code(self, chat_id: int) -> str:
|
||||||
|
"""
|
||||||
|
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 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.
|
||||||
|
|
||||||
|
**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")
|
||||||
|
|
||||||
|
# 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"Telegram user authenticated: chat_id={chat_id}, user_id={user.id}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"user_id": user.id,
|
||||||
|
"jwt_token": access_token,
|
||||||
|
"expires_at": (
|
||||||
|
datetime.utcnow() + timedelta(hours=24)
|
||||||
|
).isoformat(),
|
||||||
|
}
|
||||||
134
app/api/auth.py
134
app/api/auth.py
@@ -225,6 +225,56 @@ async def telegram_binding_confirm(
|
|||||||
return TelegramBindingConfirmResponse(**result)
|
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(
|
@router.post(
|
||||||
"/telegram/authenticate",
|
"/telegram/authenticate",
|
||||||
response_model=dict,
|
response_model=dict,
|
||||||
@@ -235,11 +285,11 @@ async def telegram_authenticate(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Get JWT token for Telegram user.
|
Get JWT token for Telegram user (bot authentication).
|
||||||
|
|
||||||
**Usage in Bot:**
|
**Usage in Bot:**
|
||||||
```python
|
```python
|
||||||
# After user binding is confirmed
|
# Get token for authenticated user
|
||||||
response = api.post("/auth/telegram/authenticate?chat_id=12345")
|
response = api.post("/auth/telegram/authenticate?chat_id=12345")
|
||||||
jwt_token = response["jwt_token"]
|
jwt_token = response["jwt_token"]
|
||||||
```
|
```
|
||||||
@@ -254,6 +304,86 @@ async def telegram_authenticate(
|
|||||||
return result
|
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(
|
@router.post(
|
||||||
"/logout",
|
"/logout",
|
||||||
summary="Logout user",
|
summary="Logout user",
|
||||||
|
|||||||
@@ -8,11 +8,12 @@ from typing import Optional, Dict, Any
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import time
|
import time
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import redis
|
||||||
from aiogram import Bot, Dispatcher, types, F
|
from aiogram import Bot, Dispatcher, types, F
|
||||||
from aiogram.filters import Command
|
from aiogram.filters import Command
|
||||||
from aiogram.types import Message
|
from aiogram.types import Message
|
||||||
import redis
|
|
||||||
import json
|
|
||||||
from app.security.hmac_manager import hmac_manager
|
from app.security.hmac_manager import hmac_manager
|
||||||
|
|
||||||
|
|
||||||
@@ -63,10 +64,16 @@ class TelegramBotClient:
|
|||||||
"""
|
"""
|
||||||
/start - Begin Telegram binding process.
|
/start - Begin Telegram binding process.
|
||||||
|
|
||||||
Flow:
|
**Flow:**
|
||||||
1. Check if user already bound
|
1. Check if user already bound (JWT in Redis)
|
||||||
2. If not: Generate binding code
|
2. If not: Generate binding code via API
|
||||||
3. Send link to user
|
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
|
chat_id = message.chat.id
|
||||||
|
|
||||||
@@ -75,70 +82,150 @@ class TelegramBotClient:
|
|||||||
existing_token = self.redis_client.get(jwt_key)
|
existing_token = self.redis_client.get(jwt_key)
|
||||||
|
|
||||||
if existing_token:
|
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
|
return
|
||||||
|
|
||||||
# Generate binding code
|
# Generate binding code
|
||||||
try:
|
try:
|
||||||
code = await self._api_call(
|
logger.info(f"Starting binding for chat_id={chat_id}")
|
||||||
|
|
||||||
|
code_response = await self._api_call(
|
||||||
method="POST",
|
method="POST",
|
||||||
endpoint="/api/v1/auth/telegram/start",
|
endpoint="/api/v1/auth/telegram/start",
|
||||||
data={"chat_id": chat_id},
|
data={"chat_id": chat_id},
|
||||||
use_jwt=False,
|
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
|
# Send binding link to user
|
||||||
binding_url = f"https://your-app.com/auth/telegram?code={binding_code}&chat_id={chat_id}"
|
|
||||||
|
|
||||||
await message.answer(
|
await message.answer(
|
||||||
f"🔗 Click to bind your account:\n\n"
|
f"🔗 **Click to bind your account:**\n\n"
|
||||||
f"[Open Account Binding]({binding_url})\n\n"
|
f"[📱 Open Account Binding]({binding_url})\n\n"
|
||||||
f"Code expires in 10 minutes.",
|
f"⏱ Code expires in 10 minutes\n\n"
|
||||||
parse_mode="Markdown"
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Binding start error: {e}")
|
logger.error(f"Binding start error: {e}", exc_info=True)
|
||||||
await message.answer("❌ Binding failed. Try again later.")
|
await message.answer("❌ Could not start binding. Try again later.")
|
||||||
|
|
||||||
# ========== Handler: /balance ==========
|
# ========== Handler: /balance ==========
|
||||||
async def cmd_balance(self, message: Message):
|
async def cmd_balance(self, message: Message):
|
||||||
"""
|
"""
|
||||||
/balance - Show wallet balances.
|
/balance - Show wallet balances.
|
||||||
|
|
||||||
Requires:
|
**Requires:**
|
||||||
- User must be bound (JWT token in Redis)
|
- User must be bound (JWT token in Redis)
|
||||||
|
- JWT obtained via binding confirmation
|
||||||
- API call with JWT auth
|
- API call with JWT auth
|
||||||
|
|
||||||
|
**Try:**
|
||||||
|
Use /start to bind your account first
|
||||||
"""
|
"""
|
||||||
chat_id = message.chat.id
|
chat_id = message.chat.id
|
||||||
|
|
||||||
# Get JWT token
|
# Get JWT token from Redis
|
||||||
jwt_token = self._get_user_jwt(chat_id)
|
jwt_token = self._get_user_jwt(chat_id)
|
||||||
|
|
||||||
if not jwt_token:
|
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
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Call API: GET /api/v1/wallets/summary?family_id=1
|
# Call API: GET /api/v1/accounts?family_id=1
|
||||||
wallets = await self._api_call(
|
accounts_response = await self._api_call(
|
||||||
method="GET",
|
method="GET",
|
||||||
endpoint="/api/v1/wallets/summary",
|
endpoint="/api/v1/accounts",
|
||||||
jwt_token=jwt_token,
|
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
|
# Format response
|
||||||
response = "💰 **Your Wallets:**\n\n"
|
response = "💰 **Your Accounts:**\n\n"
|
||||||
for wallet in wallets:
|
for account in accounts[:10]: # Limit to 10
|
||||||
response += f"📊 {wallet['name']}: ${wallet['balance']}\n"
|
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")
|
await message.answer(response, parse_mode="Markdown")
|
||||||
|
logger.info(f"Balance shown for chat_id={chat_id}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Balance fetch error: {e}")
|
logger.error(f"Balance fetch error for {chat_id}: {e}", exc_info=True)
|
||||||
await message.answer("❌ Could not fetch balance. Try again later.")
|
await message.answer(
|
||||||
|
"❌ Could not fetch balance\n\n"
|
||||||
|
"Try again later or contact support",
|
||||||
|
parse_mode="Markdown"
|
||||||
|
)
|
||||||
|
|
||||||
# ========== Handler: /add (Create Transaction) ==========
|
# ========== Handler: /add (Create Transaction) ==========
|
||||||
async def cmd_add_transaction(self, message: Message):
|
async def cmd_add_transaction(self, message: Message):
|
||||||
@@ -230,16 +317,51 @@ class TelegramBotClient:
|
|||||||
|
|
||||||
# ========== Handler: /help ==========
|
# ========== Handler: /help ==========
|
||||||
async def cmd_help(self, message: Message):
|
async def cmd_help(self, message: Message):
|
||||||
"""Show available commands"""
|
"""Show available commands and binding instructions"""
|
||||||
help_text = """
|
chat_id = message.chat.id
|
||||||
🤖 **Finance Bot Commands:**
|
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
|
🔗 **Account**
|
||||||
/balance - Show wallet balances
|
/start - Re-bind account (if needed)
|
||||||
|
/balance - Show all account balances
|
||||||
|
/settings - Account settings
|
||||||
|
|
||||||
|
💰 **Transactions**
|
||||||
/add - Add new transaction
|
/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
|
/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")
|
await message.answer(help_text, parse_mode="Markdown")
|
||||||
|
|
||||||
# ========== API Communication Methods ==========
|
# ========== API Communication Methods ==========
|
||||||
@@ -253,29 +375,40 @@ class TelegramBotClient:
|
|||||||
use_jwt: bool = True,
|
use_jwt: bool = True,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Make HTTP request to API with proper auth headers.
|
Make HTTP request to API with proper authentication headers.
|
||||||
|
|
||||||
Headers:
|
**Headers:**
|
||||||
- Authorization: Bearer <jwt_token>
|
- Authorization: Bearer <jwt_token> (if use_jwt=True)
|
||||||
- X-Client-Id: telegram_bot
|
- X-Client-Id: telegram_bot
|
||||||
- X-Signature: HMAC_SHA256(...)
|
- X-Signature: HMAC_SHA256(method + endpoint + timestamp + body)
|
||||||
- X-Timestamp: unix timestamp
|
- 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:
|
if not self.session:
|
||||||
raise RuntimeError("Session not initialized")
|
raise RuntimeError("Session not initialized")
|
||||||
|
|
||||||
|
# Build URL
|
||||||
|
url = f"{self.api_base_url}{endpoint}"
|
||||||
|
|
||||||
# Build headers
|
# Build headers
|
||||||
headers = {
|
headers = {
|
||||||
"X-Client-Id": "telegram_bot",
|
"X-Client-Id": "telegram_bot",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add JWT if provided
|
# Add JWT if provided and requested
|
||||||
if use_jwt and jwt_token:
|
if use_jwt and jwt_token:
|
||||||
headers["Authorization"] = f"Bearer {jwt_token}"
|
headers["Authorization"] = f"Bearer {jwt_token}"
|
||||||
|
|
||||||
# Add HMAC signature
|
# Add HMAC signature for integrity verification
|
||||||
timestamp = int(time.time())
|
timestamp = int(time.time())
|
||||||
headers["X-Timestamp"] = str(timestamp)
|
headers["X-Timestamp"] = str(timestamp)
|
||||||
|
|
||||||
@@ -288,20 +421,39 @@ class TelegramBotClient:
|
|||||||
headers["X-Signature"] = signature
|
headers["X-Signature"] = signature
|
||||||
|
|
||||||
# Make request
|
# Make request
|
||||||
url = f"{self.api_base_url}{endpoint}"
|
try:
|
||||||
|
async with self.session.request(
|
||||||
async with self.session.request(
|
method=method,
|
||||||
method=method,
|
url=url,
|
||||||
url=url,
|
json=data,
|
||||||
json=data,
|
params=params,
|
||||||
params=params,
|
headers=headers,
|
||||||
headers=headers,
|
timeout=aiohttp.ClientTimeout(total=10),
|
||||||
) as response:
|
) as response:
|
||||||
if response.status >= 400:
|
response_text = await response.text()
|
||||||
error_text = await response.text()
|
|
||||||
raise Exception(f"API error {response.status}: {error_text}")
|
if response.status >= 400:
|
||||||
|
logger.error(
|
||||||
return await response.json()
|
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]:
|
def _get_user_jwt(self, chat_id: int) -> Optional[str]:
|
||||||
"""Get JWT token for chat_id from Redis"""
|
"""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:
|
async def dispatch(self, request: Request, call_next: Callable) -> Any:
|
||||||
# Skip verification for public endpoints
|
# 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:
|
if request.url.path in public_paths:
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
@@ -153,7 +161,15 @@ class JWTAuthenticationMiddleware(BaseHTTPMiddleware):
|
|||||||
|
|
||||||
async def dispatch(self, request: Request, call_next: Callable) -> Any:
|
async def dispatch(self, request: Request, call_next: Callable) -> Any:
|
||||||
# Skip auth for public endpoints
|
# 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:
|
if request.url.path in public_paths:
|
||||||
return await call_next(request)
|
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 datetime import datetime, timedelta
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any
|
||||||
import secrets
|
import secrets
|
||||||
|
import json
|
||||||
from sqlalchemy.orm import Session
|
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.security.jwt_manager import jwt_manager
|
||||||
|
from app.core.config import settings
|
||||||
import logging
|
import logging
|
||||||
|
import redis
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Redis connection for caching binding codes
|
||||||
|
redis_client = redis.from_url(settings.redis_url)
|
||||||
|
|
||||||
|
|
||||||
class AuthService:
|
class AuthService:
|
||||||
"""Handles user authentication and token management"""
|
"""Handles user authentication, token management, and Telegram binding"""
|
||||||
|
|
||||||
TELEGRAM_BINDING_CODE_TTL = 600 # 10 minutes
|
TELEGRAM_BINDING_CODE_TTL = 600 # 10 minutes
|
||||||
BINDING_CODE_LENGTH = 24
|
BINDING_CODE_LENGTH = 24
|
||||||
@@ -23,41 +29,251 @@ class AuthService:
|
|||||||
self.db = db
|
self.db = db
|
||||||
|
|
||||||
async def create_telegram_binding_code(self, chat_id: int) -> str:
|
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)
|
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}")
|
logger.info(f"Generated Telegram binding code for chat_id={chat_id}")
|
||||||
return code
|
return code
|
||||||
|
|
||||||
async def login(self, email: str, password: str) -> Dict[str, Any]:
|
async def confirm_telegram_binding(
|
||||||
"""Authenticate user with email/password"""
|
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:
|
if not user:
|
||||||
raise ValueError("User not found")
|
raise ValueError("User not found")
|
||||||
|
|
||||||
# In production: verify password with bcrypt
|
# Create tokens
|
||||||
# For MVP: simple comparison (change this!)
|
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)
|
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 {
|
return {
|
||||||
"user_id": user.id,
|
"user_id": user.id,
|
||||||
"access_token": access_token,
|
"jwt_token": access_token,
|
||||||
"token_type": "bearer",
|
"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