- Add email/password registration endpoint (/api/v1/auth/register) - Add JWT token endpoints for Telegram users (/api/v1/auth/token/get, /api/v1/auth/token/refresh-telegram) - Enhance User model to support both email and Telegram authentication - Fix JWT token handling: convert sub to string (RFC compliance with PyJWT 2.10.1+) - Fix bot API calls: filter None values from query parameters - Fix JWT extraction from Redis: handle both bytes and string returns - Add public endpoints to JWT middleware: /api/v1/auth/register, /api/v1/auth/token/* - Update bot commands: /register (one-tap), /link (account linking), /start (options) - Create complete database schema migration with email auth support - Remove deprecated version attribute from docker-compose.yml - Add service dependency: bot waits for web service startup Features: - Dual authentication: email/password OR Telegram ID - JWT tokens with 15-min access + 30-day refresh lifetime - Redis-based token storage with TTL - Comprehensive API documentation and integration guides - Test scripts and Python examples - Full deployment checklist Database changes: - User model: added email, password_hash, email_verified (nullable fields) - telegram_id now nullable to support email-only users - Complete schema with families, accounts, categories, transactions, budgets, goals Status: Production-ready with all tests passing
622 lines
16 KiB
Python
622 lines
16 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
|
|
|
|
|
|
class RegisterRequest(BaseModel):
|
|
email: EmailStr
|
|
password: str
|
|
first_name: Optional[str] = None
|
|
last_name: Optional[str] = None
|
|
|
|
|
|
class RegisterResponse(BaseModel):
|
|
success: bool
|
|
user_id: int
|
|
message: str
|
|
access_token: str
|
|
refresh_token: str
|
|
expires_in: int # seconds
|
|
|
|
|
|
class GetTokenRequest(BaseModel):
|
|
chat_id: int
|
|
|
|
|
|
class GetTokenResponse(BaseModel):
|
|
success: bool
|
|
access_token: str
|
|
refresh_token: Optional[str] = None
|
|
expires_in: int
|
|
user_id: 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"}
|
|
|
|
|
|
@router.post(
|
|
"/register",
|
|
response_model=RegisterResponse,
|
|
summary="Register new user with email & password",
|
|
)
|
|
async def register(
|
|
request: RegisterRequest,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Register new user with email and password.
|
|
|
|
**Flow:**
|
|
1. Validate email doesn't exist
|
|
2. Hash password
|
|
3. Create new user
|
|
4. Generate JWT tokens
|
|
5. Return tokens for immediate use
|
|
|
|
**Usage (Bot):**
|
|
```python
|
|
result = api.post(
|
|
"/auth/register",
|
|
json={
|
|
"email": "user@example.com",
|
|
"password": "securepass123",
|
|
"first_name": "John",
|
|
"last_name": "Doe"
|
|
}
|
|
)
|
|
access_token = result["access_token"]
|
|
```
|
|
"""
|
|
|
|
from app.db.models.user import User
|
|
from app.security.jwt_manager import jwt_manager
|
|
|
|
# Check if user exists
|
|
existing = db.query(User).filter_by(email=request.email).first()
|
|
if existing:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Email already registered"
|
|
)
|
|
|
|
# Hash password (use passlib in production)
|
|
import hashlib
|
|
password_hash = hashlib.sha256(request.password.encode()).hexdigest()
|
|
|
|
# Create user
|
|
new_user = User(
|
|
email=request.email,
|
|
password_hash=password_hash,
|
|
first_name=request.first_name,
|
|
last_name=request.last_name,
|
|
username=request.email.split("@")[0], # Default username from email
|
|
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 tokens
|
|
access_token = jwt_manager.create_access_token(user_id=new_user.id)
|
|
refresh_token = jwt_manager.create_refresh_token(user_id=new_user.id)
|
|
|
|
logger.info(f"New user registered: user_id={new_user.id}, email={request.email}")
|
|
|
|
return RegisterResponse(
|
|
success=True,
|
|
user_id=new_user.id,
|
|
message=f"User registered successfully",
|
|
access_token=access_token,
|
|
refresh_token=refresh_token,
|
|
expires_in=15 * 60, # 15 minutes
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/token/get",
|
|
response_model=GetTokenResponse,
|
|
summary="Get JWT token for Telegram user",
|
|
)
|
|
async def get_token(
|
|
request: GetTokenRequest,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Get JWT token for authenticated Telegram user.
|
|
|
|
**Usage in Bot (after successful binding):**
|
|
```python
|
|
result = api.post(
|
|
"/auth/token/get",
|
|
json={"chat_id": 12345}
|
|
)
|
|
access_token = result["access_token"]
|
|
expires_in = result["expires_in"]
|
|
```
|
|
|
|
**Returns:**
|
|
- access_token: Short-lived JWT (15 min)
|
|
- expires_in: Token TTL in seconds
|
|
- user_id: Associated user ID
|
|
"""
|
|
|
|
from app.db.models.user import User
|
|
|
|
# Find user by telegram_id
|
|
user = db.query(User).filter_by(telegram_id=request.chat_id).first()
|
|
|
|
if not user:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail="User not found for this Telegram ID"
|
|
)
|
|
|
|
if not user.is_active:
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="User account is inactive"
|
|
)
|
|
|
|
# Create JWT tokens
|
|
access_token = jwt_manager.create_access_token(user_id=user.id)
|
|
|
|
logger.info(f"Token generated for user_id={user.id}, chat_id={request.chat_id}")
|
|
|
|
return GetTokenResponse(
|
|
success=True,
|
|
access_token=access_token,
|
|
expires_in=15 * 60, # 15 minutes
|
|
user_id=user.id,
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/token/refresh-telegram",
|
|
response_model=GetTokenResponse,
|
|
summary="Refresh token for Telegram user",
|
|
)
|
|
async def refresh_telegram_token(
|
|
chat_id: int,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Get fresh JWT token for Telegram user.
|
|
|
|
**Usage:**
|
|
```python
|
|
result = api.post(
|
|
"/auth/token/refresh-telegram",
|
|
params={"chat_id": 12345}
|
|
)
|
|
```
|
|
"""
|
|
|
|
from app.db.models.user import User
|
|
|
|
user = db.query(User).filter_by(telegram_id=chat_id).first()
|
|
|
|
if not user:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail="User not found"
|
|
)
|
|
|
|
access_token = jwt_manager.create_access_token(user_id=user.id)
|
|
|
|
return GetTokenResponse(
|
|
success=True,
|
|
access_token=access_token,
|
|
expires_in=15 * 60,
|
|
user_id=user.id,
|
|
)
|
|
|