diff --git a/.history/app/api/auth_20251210221252.py b/.history/app/api/auth_20251210221252.py new file mode 100644 index 0000000..9f5e245 --- /dev/null +++ b/.history/app/api/auth_20251210221252.py @@ -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 + 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"} diff --git a/.history/app/api/auth_20251210221420.py b/.history/app/api/auth_20251210221420.py new file mode 100644 index 0000000..a6aac08 --- /dev/null +++ b/.history/app/api/auth_20251210221420.py @@ -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 + 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"} diff --git a/.history/app/api/auth_20251210221448.py b/.history/app/api/auth_20251210221448.py new file mode 100644 index 0000000..8f5f4e8 --- /dev/null +++ b/.history/app/api/auth_20251210221448.py @@ -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 + 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"} diff --git a/.history/app/api/auth_20251210221526.py b/.history/app/api/auth_20251210221526.py new file mode 100644 index 0000000..8f5f4e8 --- /dev/null +++ b/.history/app/api/auth_20251210221526.py @@ -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 + 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"} diff --git a/.history/app/bot/client_20251210221303.py b/.history/app/bot/client_20251210221303.py new file mode 100644 index 0000000..eb5b858 --- /dev/null +++ b/.history/app/bot/client_20251210221303.py @@ -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 + - 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 diff --git a/.history/app/bot/client_20251210221313.py b/.history/app/bot/client_20251210221313.py new file mode 100644 index 0000000..4c60fa9 --- /dev/null +++ b/.history/app/bot/client_20251210221313.py @@ -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 + - 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 diff --git a/.history/app/bot/client_20251210221319.py b/.history/app/bot/client_20251210221319.py new file mode 100644 index 0000000..141b9ab --- /dev/null +++ b/.history/app/bot/client_20251210221319.py @@ -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 + - 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 diff --git a/.history/app/bot/client_20251210221328.py b/.history/app/bot/client_20251210221328.py new file mode 100644 index 0000000..80dc515 --- /dev/null +++ b/.history/app/bot/client_20251210221328.py @@ -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 (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 diff --git a/.history/app/bot/client_20251210221338.py b/.history/app/bot/client_20251210221338.py new file mode 100644 index 0000000..7643940 --- /dev/null +++ b/.history/app/bot/client_20251210221338.py @@ -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 (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 diff --git a/.history/app/bot/client_20251210221526.py b/.history/app/bot/client_20251210221526.py new file mode 100644 index 0000000..7643940 --- /dev/null +++ b/.history/app/bot/client_20251210221526.py @@ -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 (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 diff --git a/.history/app/security/middleware_20251210221744.py b/.history/app/security/middleware_20251210221744.py new file mode 100644 index 0000000..e126c86 --- /dev/null +++ b/.history/app/security/middleware_20251210221744.py @@ -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 " + 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) diff --git a/.history/app/security/middleware_20251210221754.py b/.history/app/security/middleware_20251210221754.py new file mode 100644 index 0000000..adf2e2f --- /dev/null +++ b/.history/app/security/middleware_20251210221754.py @@ -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 " + 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) diff --git a/.history/app/security/middleware_20251210221758.py b/.history/app/security/middleware_20251210221758.py new file mode 100644 index 0000000..adf2e2f --- /dev/null +++ b/.history/app/security/middleware_20251210221758.py @@ -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 " + 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) diff --git a/.history/app/services/auth_service_20251210221228.py b/.history/app/services/auth_service_20251210221228.py new file mode 100644 index 0000000..48d825f --- /dev/null +++ b/.history/app/services/auth_service_20251210221228.py @@ -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(), + } diff --git a/.history/app/services/auth_service_20251210221406.py b/.history/app/services/auth_service_20251210221406.py new file mode 100644 index 0000000..2c257d4 --- /dev/null +++ b/.history/app/services/auth_service_20251210221406.py @@ -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(), + } diff --git a/.history/app/services/auth_service_20251210221434.py b/.history/app/services/auth_service_20251210221434.py new file mode 100644 index 0000000..767887c --- /dev/null +++ b/.history/app/services/auth_service_20251210221434.py @@ -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(), + } diff --git a/.history/app/services/auth_service_20251210221526.py b/.history/app/services/auth_service_20251210221526.py new file mode 100644 index 0000000..767887c --- /dev/null +++ b/.history/app/services/auth_service_20251210221526.py @@ -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(), + } diff --git a/app/api/auth.py b/app/api/auth.py index f408b69..8f5f4e8 100644 --- a/app/api/auth.py +++ b/app/api/auth.py @@ -225,6 +225,56 @@ async def telegram_binding_confirm( return TelegramBindingConfirmResponse(**result) +@router.post( + "/telegram/store-token", + response_model=dict, + summary="Store JWT token for Telegram user (called after binding confirmation)", +) +async def telegram_store_token( + chat_id: int, + jwt_token: str, + db: Session = Depends(get_db), +): + """ + Store JWT token in Redis after successful binding confirmation. + + **Flow:** + 1. User confirms binding via /telegram/confirm + 2. Frontend receives jwt_token + 3. Frontend calls this endpoint to cache token in bot's Redis + 4. Bot can now use token for API calls + + **Usage:** + ``` + POST /auth/telegram/store-token?chat_id=12345&jwt_token=eyJ... + ``` + """ + + import redis + + # Get Redis client from settings + from app.core.config import settings + redis_client = redis.from_url(settings.redis_url) + + # Validate JWT token structure + try: + jwt_manager.verify_token(jwt_token) + except ValueError as e: + raise HTTPException(status_code=400, detail=f"Invalid token: {e}") + + # Store JWT in Redis with 30-day TTL + cache_key = f"chat_id:{chat_id}:jwt" + redis_client.setex(cache_key, 86400 * 30, jwt_token) + + logger.info(f"JWT token stored for chat_id={chat_id}") + + return { + "success": True, + "message": "Token stored successfully", + "chat_id": chat_id, + } + + @router.post( "/telegram/authenticate", response_model=dict, @@ -235,11 +285,11 @@ async def telegram_authenticate( db: Session = Depends(get_db), ): """ - Get JWT token for Telegram user. + Get JWT token for Telegram user (bot authentication). **Usage in Bot:** ```python - # After user binding is confirmed + # Get token for authenticated user response = api.post("/auth/telegram/authenticate?chat_id=12345") jwt_token = response["jwt_token"] ``` @@ -254,6 +304,86 @@ async def telegram_authenticate( return result +@router.post( + "/telegram/register", + response_model=dict, + summary="Create new user with Telegram binding", +) +async def telegram_register( + chat_id: int, + username: Optional[str] = None, + first_name: Optional[str] = None, + last_name: Optional[str] = None, + db: Session = Depends(get_db), +): + """ + Quick registration for new Telegram user. + + **Flow:** + 1. Bot calls this endpoint on /start + 2. Creates new User with telegram_id + 3. Returns JWT for immediate API access + 4. User can update email/password later + + **Usage in Bot:** + ```python + result = api.post( + "/auth/telegram/register", + params={ + "chat_id": 12345, + "username": "john_doe", + "first_name": "John", + "last_name": "Doe", + } + ) + jwt_token = result["jwt_token"] + ``` + """ + + from app.db.models.user import User + + # Check if user already exists + existing = db.query(User).filter_by(telegram_id=chat_id).first() + if existing: + service = AuthService(db) + result = await service.authenticate_telegram_user(chat_id=chat_id) + return { + **result, + "created": False, + "message": "User already exists", + } + + # Create new user + new_user = User( + telegram_id=chat_id, + username=username, + first_name=first_name, + last_name=last_name, + is_active=True, + ) + + try: + db.add(new_user) + db.commit() + db.refresh(new_user) + except Exception as e: + db.rollback() + logger.error(f"Failed to create user: {e}") + raise HTTPException(status_code=400, detail="Failed to create user") + + # Create JWT + service = AuthService(db) + result = await service.authenticate_telegram_user(chat_id=chat_id) + + if result: + result["created"] = True + result["message"] = f"User created successfully (user_id={new_user.id})" + + logger.info(f"New Telegram user registered: chat_id={chat_id}, user_id={new_user.id}") + + return result or {"success": False, "error": "Failed to create user"} + + @router.post( "/logout", summary="Logout user", diff --git a/app/bot/client.py b/app/bot/client.py index 7aace3b..7643940 100644 --- a/app/bot/client.py +++ b/app/bot/client.py @@ -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 + **Headers:** + - Authorization: Bearer (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""" diff --git a/app/security/middleware.py b/app/security/middleware.py index 81feffa..adf2e2f 100644 --- a/app/security/middleware.py +++ b/app/security/middleware.py @@ -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) diff --git a/app/services/auth_service.py b/app/services/auth_service.py index b013242..767887c 100644 --- a/app/services/auth_service.py +++ b/app/services/auth_service.py @@ -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")