This commit is contained in:
2025-12-10 22:18:07 +09:00
parent b79adf1c69
commit b642d1e9e9
21 changed files with 6781 additions and 86 deletions

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

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

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

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

View 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

View 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

View 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

View 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

View 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

View 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

View 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)

View 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)

View 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)

View 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(),
}

View 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(),
}

View 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(),
}

View 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(),
}

View File

@@ -225,6 +225,56 @@ async def telegram_binding_confirm(
return TelegramBindingConfirmResponse(**result)
@router.post(
"/telegram/store-token",
response_model=dict,
summary="Store JWT token for Telegram user (called after binding confirmation)",
)
async def telegram_store_token(
chat_id: int,
jwt_token: str,
db: Session = Depends(get_db),
):
"""
Store JWT token in Redis after successful binding confirmation.
**Flow:**
1. User confirms binding via /telegram/confirm
2. Frontend receives jwt_token
3. Frontend calls this endpoint to cache token in bot's Redis
4. Bot can now use token for API calls
**Usage:**
```
POST /auth/telegram/store-token?chat_id=12345&jwt_token=eyJ...
```
"""
import redis
# Get Redis client from settings
from app.core.config import settings
redis_client = redis.from_url(settings.redis_url)
# Validate JWT token structure
try:
jwt_manager.verify_token(jwt_token)
except ValueError as e:
raise HTTPException(status_code=400, detail=f"Invalid token: {e}")
# Store JWT in Redis with 30-day TTL
cache_key = f"chat_id:{chat_id}:jwt"
redis_client.setex(cache_key, 86400 * 30, jwt_token)
logger.info(f"JWT token stored for chat_id={chat_id}")
return {
"success": True,
"message": "Token stored successfully",
"chat_id": chat_id,
}
@router.post(
"/telegram/authenticate",
response_model=dict,
@@ -235,11 +285,11 @@ async def telegram_authenticate(
db: Session = Depends(get_db),
):
"""
Get JWT token for Telegram user.
Get JWT token for Telegram user (bot authentication).
**Usage in Bot:**
```python
# After user binding is confirmed
# Get token for authenticated user
response = api.post("/auth/telegram/authenticate?chat_id=12345")
jwt_token = response["jwt_token"]
```
@@ -254,6 +304,86 @@ async def telegram_authenticate(
return result
@router.post(
"/telegram/register",
response_model=dict,
summary="Create new user with Telegram binding",
)
async def telegram_register(
chat_id: int,
username: Optional[str] = None,
first_name: Optional[str] = None,
last_name: Optional[str] = None,
db: Session = Depends(get_db),
):
"""
Quick registration for new Telegram user.
**Flow:**
1. Bot calls this endpoint on /start
2. Creates new User with telegram_id
3. Returns JWT for immediate API access
4. User can update email/password later
**Usage in Bot:**
```python
result = api.post(
"/auth/telegram/register",
params={
"chat_id": 12345,
"username": "john_doe",
"first_name": "John",
"last_name": "Doe",
}
)
jwt_token = result["jwt_token"]
```
"""
from app.db.models.user import User
# Check if user already exists
existing = db.query(User).filter_by(telegram_id=chat_id).first()
if existing:
service = AuthService(db)
result = await service.authenticate_telegram_user(chat_id=chat_id)
return {
**result,
"created": False,
"message": "User already exists",
}
# Create new user
new_user = User(
telegram_id=chat_id,
username=username,
first_name=first_name,
last_name=last_name,
is_active=True,
)
try:
db.add(new_user)
db.commit()
db.refresh(new_user)
except Exception as e:
db.rollback()
logger.error(f"Failed to create user: {e}")
raise HTTPException(status_code=400, detail="Failed to create user")
# Create JWT
service = AuthService(db)
result = await service.authenticate_telegram_user(chat_id=chat_id)
if result:
result["created"] = True
result["message"] = f"User created successfully (user_id={new_user.id})"
logger.info(f"New Telegram user registered: chat_id={chat_id}, user_id={new_user.id}")
return result or {"success": False, "error": "Failed to create user"}
@router.post(
"/logout",
summary="Logout user",

View File

@@ -8,11 +8,12 @@ from typing import Optional, Dict, Any
from decimal import Decimal
import aiohttp
import time
import asyncio
import json
import redis
from aiogram import Bot, Dispatcher, types, F
from aiogram.filters import Command
from aiogram.types import Message
import redis
import json
from app.security.hmac_manager import hmac_manager
@@ -63,10 +64,16 @@ class TelegramBotClient:
"""
/start - Begin Telegram binding process.
Flow:
1. Check if user already bound
2. If not: Generate binding code
3. Send link to user
**Flow:**
1. Check if user already bound (JWT in Redis)
2. If not: Generate binding code via API
3. Send binding link to user with code
**After binding:**
- User clicks link and confirms
- User's browser calls POST /api/v1/auth/telegram/confirm
- Bot calls GET /api/v1/auth/telegram/authenticate?chat_id=XXXX
- Bot stores JWT in Redis for future API calls
"""
chat_id = message.chat.id
@@ -75,70 +82,150 @@ class TelegramBotClient:
existing_token = self.redis_client.get(jwt_key)
if existing_token:
await message.answer("✅ You're already connected!\n\nUse /help for commands.")
await message.answer(
"✅ **You're already connected!**\n\n"
"Use /balance to check wallets\n"
"Use /add to add transactions\n"
"Use /help for all commands",
parse_mode="Markdown"
)
return
# Generate binding code
try:
code = await self._api_call(
logger.info(f"Starting binding for chat_id={chat_id}")
code_response = await self._api_call(
method="POST",
endpoint="/api/v1/auth/telegram/start",
data={"chat_id": chat_id},
use_jwt=False,
)
binding_code = code.get("code")
binding_code = code_response.get("code")
if not binding_code:
raise ValueError("No code in response")
# Store binding code in Redis for validation
# (expires in 10 minutes as per backend TTL)
binding_key = f"chat_id:{chat_id}:binding_code"
self.redis_client.setex(
binding_key,
600,
json.dumps({"code": binding_code, "created_at": datetime.utcnow().isoformat()})
)
# Build binding link (replace with actual frontend URL)
# Example: https://yourapp.com/auth/telegram/confirm?code=XXX&chat_id=123
binding_url = (
f"https://your-finance-app.com/auth/telegram/confirm"
f"?code={binding_code}&chat_id={chat_id}"
)
# Send binding link to user
binding_url = f"https://your-app.com/auth/telegram?code={binding_code}&chat_id={chat_id}"
await message.answer(
f"🔗 Click to bind your account:\n\n"
f"[Open Account Binding]({binding_url})\n\n"
f"Code expires in 10 minutes.",
parse_mode="Markdown"
f"🔗 **Click to bind your account:**\n\n"
f"[📱 Open Account Binding]({binding_url})\n\n"
f"Code expires in 10 minutes\n\n"
f"❓ Already have an account? Just log in and click the link.",
parse_mode="Markdown",
disable_web_page_preview=True,
)
logger.info(f"Binding code sent to chat_id={chat_id}")
except Exception as e:
logger.error(f"Binding start error: {e}")
await message.answer("Binding failed. Try again later.")
logger.error(f"Binding start error: {e}", exc_info=True)
await message.answer("Could not start binding. Try again later.")
# ========== Handler: /balance ==========
async def cmd_balance(self, message: Message):
"""
/balance - Show wallet balances.
Requires:
**Requires:**
- User must be bound (JWT token in Redis)
- JWT obtained via binding confirmation
- API call with JWT auth
**Try:**
Use /start to bind your account first
"""
chat_id = message.chat.id
# Get JWT token
# Get JWT token from Redis
jwt_token = self._get_user_jwt(chat_id)
if not jwt_token:
await message.answer("❌ Not connected. Use /start to bind your account.")
# Try to authenticate if user exists
try:
auth_result = await self._api_call(
method="POST",
endpoint="/api/v1/auth/telegram/authenticate",
params={"chat_id": chat_id},
use_jwt=False,
)
if auth_result and auth_result.get("jwt_token"):
jwt_token = auth_result["jwt_token"]
# Store in Redis for future use
self.redis_client.setex(
f"chat_id:{chat_id}:jwt",
86400 * 30, # 30 days
jwt_token
)
logger.info(f"JWT obtained for chat_id={chat_id}")
except Exception as e:
logger.warning(f"Could not authenticate user: {e}")
if not jwt_token:
await message.answer(
"❌ Not connected yet\n\n"
"Use /start to bind your Telegram account first",
parse_mode="Markdown"
)
return
try:
# Call API: GET /api/v1/wallets/summary?family_id=1
wallets = await self._api_call(
# Call API: GET /api/v1/accounts?family_id=1
accounts_response = await self._api_call(
method="GET",
endpoint="/api/v1/wallets/summary",
endpoint="/api/v1/accounts",
jwt_token=jwt_token,
params={"family_id": 1}, # TODO: Get from context
params={"family_id": 1}, # TODO: Get from user context
use_jwt=True,
)
accounts = accounts_response if isinstance(accounts_response, list) else accounts_response.get("accounts", [])
if not accounts:
await message.answer(
"💰 No accounts found\n\n"
"Contact support to set up your first account",
parse_mode="Markdown"
)
return
# Format response
response = "💰 **Your Wallets:**\n\n"
for wallet in wallets:
response += f"📊 {wallet['name']}: ${wallet['balance']}\n"
response = "💰 **Your Accounts:**\n\n"
for account in accounts[:10]: # Limit to 10
balance = account.get("balance", 0)
currency = account.get("currency", "USD")
response += f"📊 {account.get('name', 'Account')}: {balance} {currency}\n"
await message.answer(response, parse_mode="Markdown")
logger.info(f"Balance shown for chat_id={chat_id}")
except Exception as e:
logger.error(f"Balance fetch error: {e}")
await message.answer("❌ Could not fetch balance. Try again later.")
logger.error(f"Balance fetch error for {chat_id}: {e}", exc_info=True)
await message.answer(
"❌ Could not fetch balance\n\n"
"Try again later or contact support",
parse_mode="Markdown"
)
# ========== Handler: /add (Create Transaction) ==========
async def cmd_add_transaction(self, message: Message):
@@ -230,16 +317,51 @@ class TelegramBotClient:
# ========== Handler: /help ==========
async def cmd_help(self, message: Message):
"""Show available commands"""
help_text = """
🤖 **Finance Bot Commands:**
"""Show available commands and binding instructions"""
chat_id = message.chat.id
jwt_key = f"chat_id:{chat_id}:jwt"
is_bound = self.redis_client.get(jwt_key) is not None
if is_bound:
help_text = """🤖 **Finance Bot - Commands:**
/start - Bind your Telegram account
/balance - Show wallet balances
🔗 **Account**
/start - Re-bind account (if needed)
/balance - Show all account balances
/settings - Account settings
💰 **Transactions**
/add - Add new transaction
/reports - View reports (daily/weekly/monthly)
/recent - Last 10 transactions
/category - View by category
📊 **Reports**
/daily - Daily spending report
/weekly - Weekly summary
/monthly - Monthly summary
❓ **Help**
/help - This message
"""
else:
help_text = """🤖 **Finance Bot - Getting Started**
**Step 1: Bind Your Account**
/start - Click the link to bind your Telegram account
**Step 2: Login**
Use your email and password on the binding page
**Step 3: Done!**
- /balance - View your accounts
- /add - Create transactions
- /help - See all commands
🔒 **Privacy**
Your data is encrypted and secure
Only you can access your accounts
"""
await message.answer(help_text, parse_mode="Markdown")
# ========== API Communication Methods ==========
@@ -253,29 +375,40 @@ class TelegramBotClient:
use_jwt: bool = True,
) -> Dict[str, Any]:
"""
Make HTTP request to API with proper auth headers.
Make HTTP request to API with proper authentication headers.
Headers:
- Authorization: Bearer <jwt_token>
**Headers:**
- Authorization: Bearer <jwt_token> (if use_jwt=True)
- X-Client-Id: telegram_bot
- X-Signature: HMAC_SHA256(...)
- X-Signature: HMAC_SHA256(method + endpoint + timestamp + body)
- X-Timestamp: unix timestamp
**Auth Flow:**
1. For public endpoints (binding): use_jwt=False, no Authorization header
2. For user endpoints: use_jwt=True, pass jwt_token
3. All calls include HMAC signature for integrity
**Raises:**
- Exception: API error with status code and message
"""
if not self.session:
raise RuntimeError("Session not initialized")
# Build URL
url = f"{self.api_base_url}{endpoint}"
# Build headers
headers = {
"X-Client-Id": "telegram_bot",
"Content-Type": "application/json",
}
# Add JWT if provided
# Add JWT if provided and requested
if use_jwt and jwt_token:
headers["Authorization"] = f"Bearer {jwt_token}"
# Add HMAC signature
# Add HMAC signature for integrity verification
timestamp = int(time.time())
headers["X-Timestamp"] = str(timestamp)
@@ -288,20 +421,39 @@ class TelegramBotClient:
headers["X-Signature"] = signature
# Make request
url = f"{self.api_base_url}{endpoint}"
async with self.session.request(
method=method,
url=url,
json=data,
params=params,
headers=headers,
) as response:
if response.status >= 400:
error_text = await response.text()
raise Exception(f"API error {response.status}: {error_text}")
return await response.json()
try:
async with self.session.request(
method=method,
url=url,
json=data,
params=params,
headers=headers,
timeout=aiohttp.ClientTimeout(total=10),
) as response:
response_text = await response.text()
if response.status >= 400:
logger.error(
f"API error {response.status}: {endpoint}\n"
f"Response: {response_text[:500]}"
)
raise Exception(
f"API error {response.status}: {response_text[:200]}"
)
# Parse JSON response
try:
return json.loads(response_text)
except json.JSONDecodeError:
logger.warning(f"Invalid JSON response from {endpoint}: {response_text}")
return {"data": response_text}
except asyncio.TimeoutError:
logger.error(f"API timeout: {endpoint}")
raise Exception("Request timeout")
except Exception as e:
logger.error(f"API call failed ({method} {endpoint}): {e}")
raise
def _get_user_jwt(self, chat_id: int) -> Optional[str]:
"""Get JWT token for chat_id from Redis"""

View File

@@ -86,7 +86,15 @@ class HMACVerificationMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: Callable) -> Any:
# Skip verification for public endpoints
public_paths = ["/health", "/docs", "/openapi.json", "/api/v1/auth/login", "/api/v1/auth/telegram/start"]
public_paths = [
"/health",
"/docs",
"/openapi.json",
"/api/v1/auth/login",
"/api/v1/auth/telegram/start",
"/api/v1/auth/telegram/register",
"/api/v1/auth/telegram/authenticate",
]
if request.url.path in public_paths:
return await call_next(request)
@@ -153,7 +161,15 @@ class JWTAuthenticationMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: Callable) -> Any:
# Skip auth for public endpoints
public_paths = ["/health", "/docs", "/openapi.json", "/api/v1/auth/login", "/api/v1/auth/telegram/start"]
public_paths = [
"/health",
"/docs",
"/openapi.json",
"/api/v1/auth/login",
"/api/v1/auth/telegram/start",
"/api/v1/auth/telegram/register",
"/api/v1/auth/telegram/authenticate",
]
if request.url.path in public_paths:
return await call_next(request)

View File

@@ -1,20 +1,26 @@
"""
Authentication Service - User login, token management
Authentication Service - User login, token management, Telegram binding
"""
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
import secrets
import json
from sqlalchemy.orm import Session
from app.db.models import User
from app.db.models.user import User
from app.security.jwt_manager import jwt_manager
from app.core.config import settings
import logging
import redis
logger = logging.getLogger(__name__)
# Redis connection for caching binding codes
redis_client = redis.from_url(settings.redis_url)
class AuthService:
"""Handles user authentication and token management"""
"""Handles user authentication, token management, and Telegram binding"""
TELEGRAM_BINDING_CODE_TTL = 600 # 10 minutes
BINDING_CODE_LENGTH = 24
@@ -23,41 +29,251 @@ class AuthService:
self.db = db
async def create_telegram_binding_code(self, chat_id: int) -> str:
"""Generate temporary code for Telegram user binding"""
"""
Generate temporary code for Telegram user binding.
**Flow:**
1. Bot calls /auth/telegram/start with chat_id
2. Service generates random code and stores in Redis
3. Bot builds link: https://bot.example.com/bind?code=XXX
4. Bot sends link to user in Telegram
5. User clicks link, authenticates, calls /auth/telegram/confirm
"""
code = secrets.token_urlsafe(self.BINDING_CODE_LENGTH)
# Store in Redis with TTL (10 minutes)
cache_key = f"binding_code:{code}"
cache_data = {
"chat_id": chat_id,
"created_at": datetime.utcnow().isoformat(),
}
redis_client.setex(
cache_key,
self.TELEGRAM_BINDING_CODE_TTL,
json.dumps(cache_data),
)
logger.info(f"Generated Telegram binding code for chat_id={chat_id}")
return code
async def login(self, email: str, password: str) -> Dict[str, Any]:
"""Authenticate user with email/password"""
async def confirm_telegram_binding(
self,
user_id: int,
chat_id: int,
code: str,
username: Optional[str] = None,
first_name: Optional[str] = None,
last_name: Optional[str] = None,
) -> Dict[str, Any]:
"""
Confirm Telegram binding after user clicks link.
user = self.db.query(User).filter_by(email=email).first()
**Flow:**
1. User authenticates with email/password
2. User clicks binding link: /bind?code=XXX
3. Frontend calls /auth/telegram/confirm with code
4. Service validates code from Redis
5. Service links user.id with telegram_id
6. Service returns JWT for bot to use
**Returns:**
{
"success": True,
"user_id": 123,
"jwt_token": "eyJ...",
"expires_at": "2025-12-11T12:00:00",
}
**Errors:**
- Code expired or not found
- Code chat_id mismatch
- User not found
- Binding already exists (user has different telegram_id)
"""
# Validate code from Redis
cache_key = f"binding_code:{code}"
cached_data = redis_client.get(cache_key)
if not cached_data:
logger.warning(f"Binding code not found or expired: {code}")
return {"success": False, "error": "Code expired or invalid"}
binding_data = json.loads(cached_data)
cached_chat_id = binding_data.get("chat_id")
if cached_chat_id != chat_id:
logger.warning(
f"Chat ID mismatch: expected {cached_chat_id}, got {chat_id}"
)
return {"success": False, "error": "Code mismatch"}
# Get user from database
user = self.db.query(User).filter_by(id=user_id).first()
if not user:
logger.error(f"User not found: {user_id}")
return {"success": False, "error": "User not found"}
# Check if binding already exists (user already bound to different telegram)
if user.telegram_id and user.telegram_id != chat_id:
logger.warning(
f"User {user_id} already bound to telegram_id={user.telegram_id}, "
f"attempting to bind to {chat_id}"
)
return {
"success": False,
"error": f"Account already bound to different Telegram account (chat_id={user.telegram_id})"
}
# Update user with Telegram info
user.telegram_id = chat_id
if username:
user.username = username
if first_name:
user.first_name = first_name
if last_name:
user.last_name = last_name
user.updated_at = datetime.utcnow()
try:
self.db.commit()
except Exception as e:
self.db.rollback()
logger.error(f"Database error during binding confirmation: {e}")
return {"success": False, "error": "Database error"}
# Create JWT token for bot
access_token = jwt_manager.create_access_token(user_id=user.id)
# Remove code from Redis
redis_client.delete(cache_key)
logger.info(
f"Telegram binding confirmed: user_id={user_id}, chat_id={chat_id}, "
f"username={username}"
)
return {
"success": True,
"user_id": user.id,
"jwt_token": access_token,
"expires_at": (
datetime.utcnow() + timedelta(hours=24)
).isoformat(),
}
async def create_session(
self,
user_id: int,
device_id: Optional[str] = None,
) -> tuple[str, str]:
"""
Create session and issue tokens.
**Returns:**
(access_token, refresh_token)
"""
user = self.db.query(User).filter_by(id=user_id).first()
if not user:
raise ValueError("User not found")
# In production: verify password with bcrypt
# For MVP: simple comparison (change this!)
# Create tokens
access_token = jwt_manager.create_access_token(user_id=user.id)
refresh_token = jwt_manager.create_refresh_token(user_id=user.id)
# Store refresh token in Redis for validation
token_key = f"refresh_token:{refresh_token}"
token_data = {
"user_id": user.id,
"device_id": device_id,
"created_at": datetime.utcnow().isoformat(),
}
# Refresh token valid for 30 days
redis_client.setex(
token_key,
86400 * 30,
json.dumps(token_data),
)
# Update user activity
user.last_activity = datetime.utcnow()
self.db.commit()
logger.info(f"Session created for user_id={user_id}")
return access_token, refresh_token
async def refresh_access_token(
self,
refresh_token: str,
user_id: int,
) -> str:
"""
Issue new access token using valid refresh token.
**Flow:**
1. Check refresh token in Redis
2. Validate it belongs to user_id
3. Create new access token
4. Return new token (don't invalidate refresh token)
"""
token_key = f"refresh_token:{refresh_token}"
token_data = redis_client.get(token_key)
if not token_data:
logger.warning(f"Refresh token not found: {user_id}")
raise ValueError("Invalid refresh token")
data = json.loads(token_data)
if data.get("user_id") != user_id:
logger.warning(f"Refresh token user mismatch: {user_id}")
raise ValueError("Token user mismatch")
# Create new access token
access_token = jwt_manager.create_access_token(user_id=user_id)
logger.info(f"Access token refreshed for user_id={user_id}")
return access_token
async def authenticate_telegram_user(self, chat_id: int) -> Optional[Dict[str, Any]]:
"""
Get JWT token for Telegram user (bot authentication).
**Flow:**
1. Bot queries: GET /auth/telegram/authenticate?chat_id=12345
2. Service finds user by telegram_id
3. Service creates/returns JWT
4. Bot stores JWT for API calls
**Returns:**
{
"user_id": 123,
"jwt_token": "eyJ...",
"expires_at": "2025-12-11T12:00:00",
} or None if not found
"""
user = self.db.query(User).filter_by(telegram_id=chat_id).first()
if not user:
logger.warning(f"Telegram user not found: {chat_id}")
return None
# Create JWT token
access_token = jwt_manager.create_access_token(user_id=user.id)
logger.info(f"User {user.id} logged in")
logger.info(f"Telegram user authenticated: chat_id={chat_id}, user_id={user.id}")
return {
"user_id": user.id,
"access_token": access_token,
"token_type": "bearer",
"jwt_token": access_token,
"expires_at": (
datetime.utcnow() + timedelta(hours=24)
).isoformat(),
}
async def refresh_token(self, refresh_token: str) -> Dict[str, Any]:
"""Refresh access token"""
try:
payload = jwt_manager.verify_token(refresh_token)
new_token = jwt_manager.create_access_token(user_id=payload.user_id)
return {
"access_token": new_token,
"token_type": "bearer",
}
except Exception as e:
logger.error(f"Token refresh failed: {e}")
raise ValueError("Invalid refresh token")