""" API Router pentru Telegram Bot Integration Furnizează endpoint-uri pentru autentificare, linking și export rapoarte pentru Telegram bot """ from fastapi import APIRouter, Depends, HTTPException, Request from typing import List, Optional, Dict, Any import sys import os import secrets import string import httpx from datetime import datetime, timedelta from pydantic import BaseModel, Field sys.path.append(os.path.join(os.path.dirname(__file__), '../../../../shared')) from auth.dependencies import get_current_user from auth.models import CurrentUser from auth.jwt_handler import jwt_handler from database.oracle_pool import oracle_pool # Telegram bot internal API URL (running on same server) TELEGRAM_BOT_INTERNAL_API = os.getenv("TELEGRAM_BOT_INTERNAL_API", "http://localhost:8002") router = APIRouter(redirect_slashes=False) # ==================== Schemas ==================== class GenerateCodeRequest(BaseModel): """Request pentru generarea unui cod de linking""" telegram_user_id: int = Field(description="ID-ul utilizatorului Telegram") telegram_username: Optional[str] = Field(default=None, description="Username-ul Telegram") telegram_first_name: Optional[str] = Field(default=None, description="Prenumele utilizatorului") telegram_last_name: Optional[str] = Field(default=None, description="Numele utilizatorului") class GenerateCodeResponse(BaseModel): """Response pentru generarea unui cod de linking""" linking_code: str = Field(description="Codul de linking generat (8 caractere)") expires_at: datetime = Field(description="Data și ora expirării codului") expires_in_minutes: int = Field(description="Minutele până la expirare") class VerifyUserRequest(BaseModel): """ Request pentru verificarea utilizatorului în Oracle Suportă 2 flow-uri: 1. Auto-linking (recomandat): doar linking_code și oracle_username - Bot-ul verifică codul în SQLite, extrage oracle_username - Backend face lookup în Oracle fără verificare parolă - Codul valid este proof-of-authorization 2. Full verification (opțional): username, password, linking_code - Verificare completă cu parolă în Oracle """ linking_code: str = Field(description="Codul de linking de la /generate-code") oracle_username: Optional[str] = Field(default=None, description="Username Oracle (pentru auto-linking)") username: Optional[str] = Field(default=None, description="Username pentru verificare completă") password: Optional[str] = Field(default=None, description="Parolă pentru verificare completă") class VerifyUserResponse(BaseModel): """Response pentru verificarea utilizatorului""" success: bool = Field(description="True dacă verificarea a avut succes") access_token: Optional[str] = Field(default=None, description="JWT access token") refresh_token: Optional[str] = Field(default=None, description="JWT refresh token") user: Optional[Dict[str, Any]] = Field(default=None, description="Detalii utilizator") message: str = Field(description="Mesaj de status") class RefreshTokenRequest(BaseModel): """Request pentru refresh JWT token""" refresh_token: str = Field(description="Refresh token-ul obținut la autentificare") class RefreshTokenResponse(BaseModel): """Response pentru refresh token""" access_token: str = Field(description="Noul JWT access token") expires_in: int = Field(description="Timpul de expirare în secunde") token_type: str = Field(default="bearer", description="Tipul token-ului") class ExportReportRequest(BaseModel): """Request pentru exportul unui raport""" company_id: int = Field(description="ID-ul firmei") report_type: str = Field(description="Tipul raportului (invoices, payments, dashboard)") format: str = Field(default="excel", description="Formatul exportului (excel, pdf, csv)") filters: Optional[Dict[str, Any]] = Field(default=None, description="Filtre pentru raport") class ExportReportResponse(BaseModel): """Response pentru exportul raportului""" success: bool = Field(description="True dacă exportul a avut succes") file_url: Optional[str] = Field(default=None, description="URL-ul fișierului generat") file_name: Optional[str] = Field(default=None, description="Numele fișierului generat") file_size_bytes: Optional[int] = Field(default=None, description="Mărimea fișierului în bytes") message: str = Field(description="Mesaj de status") class VerifyEmailRequest(BaseModel): """Request pentru verificarea email-ului în Oracle""" email: str = Field(description="Adresa de email Oracle") class VerifyEmailResponse(BaseModel): """Response pentru verificarea email-ului""" success: bool = Field(description="True dacă email-ul există și este activ") username: Optional[str] = Field(default=None, description="Username-ul Oracle asociat") message: str = Field(description="Mesaj de status") class TelegramEmailLoginRequest(BaseModel): """Request pentru autentificare prin email + parolă""" email: str = Field(description="Adresa de email Oracle") password: str = Field(description="Parola Oracle") telegram_user_id: int = Field(description="ID-ul utilizatorului Telegram") session_token: str = Field(description="Token de sesiune pentru preveni spoofing") class TelegramEmailLoginResponse(BaseModel): """Response pentru autentificare prin email + parolă""" success: bool = Field(description="True dacă autentificarea a avut succes") access_token: Optional[str] = Field(default=None, description="JWT access token") refresh_token: Optional[str] = Field(default=None, description="JWT refresh token") token_type: str = Field(default="bearer", description="Tipul token-ului") user_id: Optional[int] = Field(default=None, description="ID-ul utilizatorului Oracle") username: Optional[str] = Field(default=None, description="Username-ul Oracle") companies: List[Dict[str, Any]] = Field(default_factory=list, description="Lista companiilor") message: str = Field(description="Mesaj de status") # ==================== Helper Functions ==================== # Rate limiting storage (in-memory) from collections import defaultdict _endpoint_rate_limits = defaultdict(list) def check_endpoint_rate_limit( identifier: str, max_attempts: int = 5, window_minutes: int = 5 ) -> bool: """Backend rate limiting for sensitive endpoints""" now = datetime.now() cutoff = now - timedelta(minutes=window_minutes) # Clean old attempts _endpoint_rate_limits[identifier] = [ attempt for attempt in _endpoint_rate_limits[identifier] if attempt > cutoff ] # Check limit if len(_endpoint_rate_limits[identifier]) >= max_attempts: return False # Add attempt _endpoint_rate_limits[identifier].append(now) return True def verify_session_token( telegram_user_id: int, email: str, token: str ) -> bool: """ Verify session token from bot to prevent user ID spoofing Token format: user_id:email:signature """ import hashlib try: parts = token.split(":") if len(parts) != 3: return False token_user_id, token_email, signature = parts # Verify user ID and email match if int(token_user_id) != telegram_user_id or token_email != email: return False # Verify signature secret = os.getenv("AUTH_SESSION_SECRET", "change-me-in-production") payload = f"{telegram_user_id}:{email}:{secret}" expected_signature = hashlib.sha256(payload.encode()).hexdigest()[:16] if signature != expected_signature: return False return True except Exception: return False def generate_linking_code(length: int = 8) -> str: """ Generează un cod alfanumeric aleatoriu pentru linking Args: length: Lungimea codului (default: 8) Returns: Codul generat (uppercase alphanumeric) """ alphabet = string.ascii_uppercase + string.digits # Exclude caractere care pot fi confundate: 0, O, I, 1 alphabet = alphabet.replace('0', '').replace('O', '').replace('I', '').replace('1', '') return ''.join(secrets.choice(alphabet) for _ in range(length)) async def get_oracle_user_by_username(username: str) -> Optional[Dict[str, Any]]: """ Obține informații despre utilizator din Oracle FĂRĂ verificare parolă. Folosit pentru auto-linking când utilizatorul a fost deja autentificat prin generarea unui linking code valid în aplicația web. Args: username: Username-ul utilizatorului Oracle Returns: Dict cu informații despre utilizator sau None dacă nu există """ try: async with oracle_pool.get_connection() as connection: with connection.cursor() as cursor: # Obține detalii utilizator cursor.execute(""" SELECT ID_UTIL, UTILIZATOR FROM UTILIZATORI WHERE UPPER(UTILIZATOR) = :username """, {'username': username.upper()}) user_row = cursor.fetchone() if not user_row: return None user_id = user_row[0] actual_username = user_row[1] # Obține companiile utilizatorului cursor.execute(""" SELECT A.ID_FIRMA, A.FIRMA FROM V_NOM_FIRME A WHERE A.ID_FIRMA IN ( SELECT ID_FIRMA FROM VDEF_UTIL_FIRME WHERE ID_PROGRAM = 2 AND ID_UTIL = :user_id ) ORDER BY A.FIRMA """, {'user_id': user_id}) companies_result = cursor.fetchall() companies = [str(row[0]) for row in companies_result] return { 'user_id': user_id, 'username': actual_username, 'companies': companies, 'permissions': ['read', 'reports'] } except Exception as e: print(f"Error getting Oracle user by username: {e}") return None async def verify_oracle_user(username: str, password: str) -> Optional[Dict[str, Any]]: """ Verifică utilizatorul în Oracle folosind pack_drepturi.verificautilizator Args: username: Username-ul utilizatorului password: Parola utilizatorului Returns: Dict cu informații despre utilizator sau None dacă verificarea eșuează """ try: async with oracle_pool.get_connection() as connection: with connection.cursor() as cursor: # Verifică autentificarea cursor.execute(""" SELECT pack_drepturi.verificautilizator(:username, :password) FROM DUAL """, { 'username': username.upper(), 'password': password }) result = cursor.fetchone() verification_result = result[0] if result else -1 if verification_result == -1: return None # Obține detalii utilizator cursor.execute(""" SELECT ID_UTIL, UTILIZATOR FROM UTILIZATORI WHERE UPPER(UTILIZATOR) = :username """, {'username': username.upper()}) user_row = cursor.fetchone() if not user_row: return None user_id = user_row[0] # Obține companiile utilizatorului cursor.execute(""" SELECT A.ID_FIRMA, A.FIRMA FROM V_NOM_FIRME A WHERE A.ID_FIRMA IN ( SELECT ID_FIRMA FROM VDEF_UTIL_FIRME WHERE ID_PROGRAM = 2 AND ID_UTIL = :user_id ) ORDER BY A.FIRMA """, {'user_id': user_id}) companies_result = cursor.fetchall() companies = [str(row[0]) for row in companies_result] return { 'user_id': user_id, 'username': username, 'companies': companies, 'permissions': ['read', 'reports'] } except Exception as e: print(f"Error verifying Oracle user: {e}") return None # ==================== Endpoints ==================== @router.post("/auth/generate-code", response_model=GenerateCodeResponse) async def generate_linking_code_endpoint( current_user: CurrentUser = Depends(get_current_user) ): """ Generează un cod de linking pentru conectarea unui utilizator Telegram Flow: 1. Utilizatorul autentificat în aplicație solicită un cod 2. Se generează un cod unic de 8 caractere 3. Codul este trimis la Telegram bot pentru salvare în SQLite cu TTL de 15 minute 4. Utilizatorul introduce codul în Telegram bot pentru linking Note: - Acest endpoint necesită autentificare JWT (utilizatorul trebuie să fie logat în aplicație) - Codul expiră după 15 minute - Fiecare request generează un cod nou (codurile vechi devin invalide) - Nu este nevoie de telegram_user_id în acest moment (utilizatorul nu e încă conectat la Telegram) """ try: # Generează cod unic linking_code = generate_linking_code() # Setează expirarea la 15 minute expires_at = datetime.utcnow() + timedelta(minutes=15) expires_in_minutes = 15 # Salvează codul în database-ul Telegram bot (SQLite) via internal API try: async with httpx.AsyncClient(timeout=5.0) as client: save_code_response = await client.post( f"{TELEGRAM_BOT_INTERNAL_API}/internal/save-code", json={ "code": linking_code, "telegram_user_id": 0, # Not known yet (user hasn't linked) "oracle_username": current_user.username, "expires_in_minutes": expires_in_minutes } ) # Accept both 200 (OK) and 201 (Created) as success if save_code_response.status_code not in [200, 201]: raise HTTPException( status_code=500, detail=f"Failed to save code to Telegram bot: {save_code_response.text}" ) except httpx.TimeoutException: raise HTTPException( status_code=503, detail="Telegram bot service is not responding. Please try again later." ) except httpx.ConnectError: raise HTTPException( status_code=503, detail="Cannot connect to Telegram bot service. Please contact administrator." ) return GenerateCodeResponse( linking_code=linking_code, expires_at=expires_at, expires_in_minutes=expires_in_minutes ) except HTTPException: raise except Exception as e: raise HTTPException( status_code=500, detail=f"Eroare la generarea codului de linking: {str(e)}" ) @router.post("/auth/verify-user", response_model=VerifyUserResponse) async def verify_user_endpoint(request: VerifyUserRequest): """ Verifică utilizatorul în Oracle și returnează JWT tokens Suportă 2 flow-uri de autentificare: Flow A - Auto-linking (RECOMANDAT): 1. Bot verifică linking_code în SQLite (code valid = user s-a autentificat în web app) 2. Bot extrage oracle_username din cod 3. Bot trimite: {linking_code, oracle_username} 4. Backend face lookup în Oracle (FĂRĂ verificare parolă) 5. Backend generează și returnează JWT tokens Flow B - Full verification (OPȚIONAL): 1. Bot cere username și parolă de la user în Telegram 2. Bot trimite: {linking_code, username, password} 3. Backend verifică credențialele în Oracle 4. Backend generează și returnează JWT tokens Note: - Acest endpoint NU necesită autentificare JWT (este public pentru bot) - Flow A oferă UX superior (fără re-introducere parolă) - Linking code-ul valid este proof-of-authorization """ try: # Flow A: Auto-linking (oracle_username provided, no password) if request.oracle_username and not request.password: user_data = await get_oracle_user_by_username(request.oracle_username) if not user_data: return VerifyUserResponse( success=False, message=f"Utilizatorul {request.oracle_username} nu există în Oracle" ) # Flow B: Full verification (username + password provided) elif request.username and request.password: user_data = await verify_oracle_user(request.username, request.password) if not user_data: return VerifyUserResponse( success=False, message="Username sau parolă incorectă" ) # Invalid request (missing required fields) else: return VerifyUserResponse( success=False, message="Trebuie furnizat fie oracle_username (auto-linking) fie username+password (verificare completă)" ) # Generează JWT tokens access_token = jwt_handler.create_access_token( username=user_data['username'], companies=user_data['companies'], user_id=user_data['user_id'], permissions=user_data['permissions'] ) refresh_token = jwt_handler.create_refresh_token( username=user_data['username'], user_id=user_data['user_id'] ) return VerifyUserResponse( success=True, access_token=access_token, refresh_token=refresh_token, user={ 'user_id': user_data['user_id'], 'username': user_data['username'], 'companies': user_data['companies'], 'permissions': user_data['permissions'] }, message="Autentificare reușită" ) except Exception as e: raise HTTPException( status_code=500, detail=f"Eroare la verificarea utilizatorului: {str(e)}" ) @router.post("/auth/refresh-token", response_model=RefreshTokenResponse) async def refresh_token_endpoint(request: RefreshTokenRequest): """ Refresh-uiește un JWT access token folosind refresh token-ul Acest endpoint este folosit de Telegram bot pentru a obține un nou access token când cel curent expiră, fără a solicita din nou username/password. Flow: 1. Botul Telegram detectează că access token-ul a expirat 2. Trimite refresh token-ul la acest endpoint 3. Se validează refresh token-ul și se generează un nou access token 4. Botul stochează noul access token în SQLite Note: - Refresh token-ul este valid 7 zile (vs 30 minute pentru access token) - Dacă refresh token-ul expiră, utilizatorul trebuie să se re-autentifice """ try: # Verifică refresh token-ul token_data = jwt_handler.verify_token(request.refresh_token) if not token_data or token_data.token_type != "refresh": raise HTTPException( status_code=401, detail="Refresh token invalid sau expirat" ) # Obține companiile actualizate din Oracle async with oracle_pool.get_connection() as connection: with connection.cursor() as cursor: cursor.execute(""" SELECT A.ID_FIRMA FROM V_NOM_FIRME A WHERE A.ID_FIRMA IN ( SELECT ID_FIRMA FROM VDEF_UTIL_FIRME WHERE ID_PROGRAM = 2 AND ID_UTIL = :user_id ) ORDER BY A.FIRMA """, {'user_id': token_data.user_id}) companies_result = cursor.fetchall() companies = [str(row[0]) for row in companies_result] # Generează nou access token new_access_token = jwt_handler.create_access_token( username=token_data.username, companies=companies, user_id=token_data.user_id, permissions=token_data.permissions ) return RefreshTokenResponse( access_token=new_access_token, expires_in=jwt_handler.access_token_expire_minutes * 60, token_type="bearer" ) except HTTPException: raise except Exception as e: raise HTTPException( status_code=500, detail=f"Eroare la refresh token: {str(e)}" ) @router.post("/auth/verify-email", response_model=VerifyEmailResponse) async def verify_email_endpoint(request: VerifyEmailRequest): """ Verify if email exists in Oracle UTILIZATORI table (PUBLIC endpoint) This is a PUBLIC endpoint used by the telegram bot during email authentication. Returns username if email exists and user is active. Security: Generic error messages to prevent email enumeration. """ try: async with oracle_pool.get_connection() as connection: with connection.cursor() as cursor: # Query to find username by email cursor.execute(""" SELECT UTILIZATOR FROM CONTAFIN_ORACLE.UTILIZATORI WHERE UPPER(EMAIL) = UPPER(:email) AND INACTIV = 0 AND STERS = 0 """, {"email": request.email}) row = cursor.fetchone() if row: username = row[0] return VerifyEmailResponse( success=True, username=username, message="Email verificat cu succes" ) else: # Generic message (no enumeration) return VerifyEmailResponse( success=False, username=None, message="Email invalid sau inactiv" ) except Exception as e: # Generic error message (no details exposed) return VerifyEmailResponse( success=False, username=None, message="Eroare la verificarea email-ului" ) @router.post("/auth/login-with-email", response_model=TelegramEmailLoginResponse) async def login_with_email_endpoint(request: TelegramEmailLoginRequest): """ Telegram email + password authentication endpoint Security features: - Rate limiting: 5 attempts per 5 minutes - Session token verification (prevent user ID spoofing) - Generic error messages (no username/email enumeration) - Password verification in Oracle (not stored) """ # 1. Rate limiting rate_limit_key = f"email_login_{request.telegram_user_id}" if not check_endpoint_rate_limit(rate_limit_key, max_attempts=5, window_minutes=5): raise HTTPException( status_code=429, detail="Prea multe încercări. Te rugăm să aștepți 5 minute." ) # 2. Verify session token (prevent user ID spoofing) if not verify_session_token( request.telegram_user_id, request.email, request.session_token ): raise HTTPException( status_code=401, detail="Sesiune invalidă. Te rugăm să reîncepi autentificarea." ) try: async with oracle_pool.get_connection() as connection: with connection.cursor() as cursor: # 3. Find username by email cursor.execute(""" SELECT ID_UTIL, UTILIZATOR, INACTIV, STERS FROM CONTAFIN_ORACLE.UTILIZATORI WHERE UPPER(EMAIL) = UPPER(:email) """, {"email": request.email}) user_row = cursor.fetchone() # SECURITY: Generic error message (no email enumeration) if not user_row: raise HTTPException( status_code=401, detail="Credențiale invalide" # Generic message ) user_id, username, inactiv, sters = user_row # Check if user is active (INACTIV=0 means active, STERS=0 means not deleted) if inactiv != 0 or sters != 0: raise HTTPException( status_code=401, detail="Credențiale invalide" # Generic message ) # 4. Verify password via Oracle stored procedure # NOTE: This procedure returns a verification code, NOT the user_id! # Returns -1 if authentication fails, any other value means success cursor.execute(""" SELECT pack_drepturi.verificautilizator(:username, :password) FROM DUAL """, { "username": username.upper(), # IMPORTANT: Oracle usernames are uppercase "password": request.password }) verification_result = cursor.fetchone()[0] # SECURITY: Generic error message (no username leak) if verification_result == -1: raise HTTPException( status_code=401, detail="Credențiale invalide" # Generic message ) # 5. Get user companies cursor.execute(""" SELECT A.ID_FIRMA, A.FIRMA FROM V_NOM_FIRME A WHERE A.ID_FIRMA IN ( SELECT ID_FIRMA FROM VDEF_UTIL_FIRME WHERE ID_PROGRAM = 2 AND ID_UTIL = :user_id ) ORDER BY A.FIRMA """, {'user_id': user_id}) companies_result = cursor.fetchall() companies = [ {"id": str(row[0]), "name": row[1]} for row in companies_result ] company_ids = [str(row[0]) for row in companies_result] # 6. Get user permissions (default for Telegram) permissions = ['read', 'reports'] # 7. Generate JWT tokens token_data = { "username": username, "user_id": user_id, "companies": company_ids, "permissions": permissions } access_token = jwt_handler.create_access_token(**token_data) refresh_token = jwt_handler.create_refresh_token( username=username, user_id=user_id ) return TelegramEmailLoginResponse( success=True, access_token=access_token, refresh_token=refresh_token, user_id=user_id, username=username, companies=companies, message="Autentificare reușită" ) except HTTPException: raise except Exception as e: print(f"Error in login_with_email: {e}") raise HTTPException( status_code=500, detail="Eroare internă. Te rugăm să încerci din nou mai târziu." ) @router.post("/export", response_model=ExportReportResponse) async def export_report_endpoint( request: ExportReportRequest, current_user: CurrentUser = Depends(get_current_user) ): """ Exportă un raport în format Excel, PDF sau CSV Acest endpoint este folosit de Telegram bot pentru a genera rapoarte și a le trimite utilizatorului. Flow: 1. Botul trimite cerere de export cu parametrii raportului 2. Se validează că utilizatorul are acces la firma specificată 3. Se generează raportul în formatul solicitat 4. Se returnează URL-ul sau conținutul fișierului Tipuri de rapoarte suportate: - invoices: Facturi (cu filtre: dată, status, client) - payments: Încasări (cu filtre: dată, metodă plată) - dashboard: Statistici dashboard (rezumat) Formate suportate: - excel: XLSX (cel mai complet) - pdf: PDF (pentru printing) - csv: CSV (pentru import în alte sisteme) Note: - Utilizatorul trebuie să aibă acces la firma specificată - Fișierele generate sunt temporare (șterse după 1 oră) """ try: # Verifică accesul la firmă company_id_str = str(request.company_id) if company_id_str not in current_user.companies: raise HTTPException( status_code=403, detail=f"Nu aveți acces la firma {request.company_id}" ) # TODO: Implementare export în funcție de report_type și format # Deocamdată returnăm un placeholder return ExportReportResponse( success=True, file_url=f"/api/telegram/downloads/report_{request.report_type}_{request.company_id}.{request.format}", file_name=f"raport_{request.report_type}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.{request.format}", file_size_bytes=0, message=f"Raport {request.report_type} generat cu succes în format {request.format}" ) except HTTPException: raise except Exception as e: raise HTTPException( status_code=500, detail=f"Eroare la generarea raportului: {str(e)}" ) @router.get("/health") async def telegram_health_check(): """ Health check pentru routerul Telegram Verifică conectivitatea la Oracle și disponibilitatea serviciilor """ try: async with oracle_pool.get_connection() as connection: with connection.cursor() as cursor: cursor.execute("SELECT 1 FROM DUAL") return { "status": "healthy", "service": "telegram-router", "database": "connected", "timestamp": datetime.utcnow().isoformat() } except Exception as e: return { "status": "degraded", "service": "telegram-router", "database": f"error: {str(e)}", "timestamp": datetime.utcnow().isoformat() }