Files
finance_bot/app/api/auth.py
2025-12-10 22:18:07 +09:00

410 lines
10 KiB
Python

"""
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"}