- Add A-Z alphabetical filter keyboard for clients and suppliers lists (same pattern as company selection, without emoji) - Increase clients/suppliers list pagination from 10 to 20 items per page - Remove emoji from company A-Z filter button for consistency - Add 6 new callback handlers: clients_alpha_menu, clients_alpha:LETTER, clients_alpha_page:PAGE:LETTER, and supplier equivalents - Dashboard service and models updates - Telegram bot: email handlers, auth, DB operations, internal API improvements - Frontend: dashboard cards updates (CashFlow, Clienti, Furnizori, Treasury) - Frontend: SolduriCompactCard and CollapsibleCard improvements - DashboardView enhancements - start.sh and run-with-restart.sh script updates - IIS web.config and service worker updates Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
946 lines
36 KiB
Python
946 lines
36 KiB
Python
"""
|
|
API Router pentru Telegram Bot Integration
|
|
Furnizează endpoint-uri pentru autentificare, linking și export rapoarte pentru Telegram bot
|
|
"""
|
|
from fastapi import APIRouter, Body, Depends, HTTPException, Request
|
|
from typing import List, Optional, Dict, Any
|
|
# import sys # Removed - no longer needed
|
|
import os
|
|
import secrets
|
|
import string
|
|
import httpx
|
|
from datetime import datetime, timedelta
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
from shared.auth.dependencies import get_current_user
|
|
from shared.auth.models import CurrentUser
|
|
from shared.auth.jwt_handler import jwt_handler
|
|
from shared.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:8000/api/telegram")
|
|
|
|
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ă")
|
|
server_id: Optional[str] = Field(default=None, description="Oracle server ID (pentru multi-server mode)")
|
|
|
|
|
|
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")
|
|
server_id: Optional[str] = Field(default=None, description="Oracle server ID (pentru multi-server mode)")
|
|
|
|
|
|
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")
|
|
server_id: Optional[str] = Field(default=None, description="Oracle server ID (pentru multi-server mode)")
|
|
|
|
|
|
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")
|
|
|
|
|
|
class SwitchServerRequest(BaseModel):
|
|
"""Request pentru schimbarea serverului Oracle"""
|
|
oracle_username: str = Field(description="Username Oracle al utilizatorului curent")
|
|
new_server_id: str = Field(description="ID-ul noului server Oracle")
|
|
oracle_password: Optional[str] = Field(default=None, description="Parola Oracle pe noul server (obligatorie dacă servere diferite au parole diferite)")
|
|
|
|
|
|
class SwitchServerResponse(BaseModel):
|
|
"""Response pentru schimbarea serverului Oracle"""
|
|
success: bool = Field(description="True dacă schimbarea a reușit")
|
|
access_token: Optional[str] = Field(default=None, description="Noul JWT access token")
|
|
refresh_token: Optional[str] = Field(default=None, description="Noul JWT refresh token")
|
|
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, server_id: Optional[str] = None) -> 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
|
|
server_id: ID-ul serverului Oracle (pentru multi-server mode)
|
|
|
|
Returns:
|
|
Dict cu informații despre utilizator sau None dacă nu există
|
|
"""
|
|
try:
|
|
async with oracle_pool.get_connection(server_id) 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, server_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Verifică utilizatorul în Oracle folosind pack_drepturi.verificautilizator
|
|
|
|
Args:
|
|
username: Username-ul utilizatorului
|
|
password: Parola utilizatorului
|
|
server_id: ID-ul serverului Oracle (pentru multi-server mode)
|
|
|
|
Returns:
|
|
Dict cu informații despre utilizator sau None dacă verificarea eșuează
|
|
"""
|
|
try:
|
|
async with oracle_pool.get_connection(server_id) 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(
|
|
request: Request,
|
|
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)
|
|
- server_id este extras automat din JWT (setat de auth middleware în request.state)
|
|
"""
|
|
try:
|
|
# Extrage server_id din JWT (setat de auth middleware)
|
|
server_id = getattr(request.state, 'server_id', None)
|
|
|
|
# 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,
|
|
"server_id": server_id
|
|
}
|
|
)
|
|
|
|
# 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, server_id=request.server_id)
|
|
|
|
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, server_id=request.server_id)
|
|
|
|
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 (cu server_id pentru multi-server mode)
|
|
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'],
|
|
server_id=request.server_id
|
|
)
|
|
|
|
refresh_token = jwt_handler.create_refresh_token(
|
|
username=user_data['username'],
|
|
user_id=user_data['user_id'],
|
|
server_id=request.server_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 (folosind server_id din refresh token)
|
|
async with oracle_pool.get_connection(token_data.server_id) 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 (păstrează server_id din refresh 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,
|
|
server_id=token_data.server_id
|
|
)
|
|
|
|
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(request.server_id) 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(request.server_id) 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 (with server_id for multi-server routing)
|
|
access_token = jwt_handler.create_access_token(
|
|
username=username,
|
|
user_id=user_id,
|
|
companies=company_ids,
|
|
permissions=permissions,
|
|
server_id=request.server_id
|
|
)
|
|
refresh_token = jwt_handler.create_refresh_token(
|
|
username=username,
|
|
user_id=user_id,
|
|
server_id=request.server_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("/auth/switch-server", response_model=SwitchServerResponse)
|
|
async def switch_server_endpoint(
|
|
request: SwitchServerRequest,
|
|
current_user: CurrentUser = Depends(get_current_user)
|
|
):
|
|
"""
|
|
Schimbă serverul Oracle activ fără re-autentificare.
|
|
|
|
Flux:
|
|
1. Verifică că oracle_username din request == username din JWT (anti-impersonare)
|
|
2. Verifică că utilizatorul există pe noul server (fără parolă, prin get_oracle_user_by_username)
|
|
3. Generează JWT nou cu noul server_id
|
|
4. Returnează tokenurile noi
|
|
|
|
Securitate: endpoint protejat cu Bearer JWT valid (Depends(get_current_user)).
|
|
"""
|
|
# Anti-impersonare: utilizatorul poate schimba serverul doar pentru propriul cont
|
|
if request.oracle_username.upper() != current_user.username.upper():
|
|
raise HTTPException(status_code=403, detail="Acces interzis: username nepotrivit")
|
|
|
|
# Verifică că utilizatorul există pe noul server
|
|
user_data = await get_oracle_user_by_username(request.oracle_username, request.new_server_id)
|
|
if not user_data:
|
|
return SwitchServerResponse(
|
|
success=False,
|
|
message=f"Utilizatorul nu există pe serverul {request.new_server_id}"
|
|
)
|
|
|
|
# Dacă parola e furnizată, verifică-o pe noul server înainte de a emite JWT
|
|
if request.oracle_password:
|
|
try:
|
|
async with oracle_pool.get_connection(request.new_server_id) as connection:
|
|
with connection.cursor() as cursor:
|
|
cursor.execute("""
|
|
SELECT pack_drepturi.verificautilizator(:username, :password)
|
|
FROM DUAL
|
|
""", {
|
|
"username": request.oracle_username.upper(),
|
|
"password": request.oracle_password
|
|
})
|
|
verification_result = cursor.fetchone()[0]
|
|
|
|
if verification_result == -1:
|
|
return SwitchServerResponse(
|
|
success=False,
|
|
message="Parolă incorectă pentru acest server"
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Password verification error during server switch: {e}")
|
|
return SwitchServerResponse(
|
|
success=False,
|
|
message="Eroare la verificarea parolei pe noul server"
|
|
)
|
|
|
|
# Generează JWT nou cu noul server_id
|
|
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'],
|
|
server_id=request.new_server_id
|
|
)
|
|
refresh_token = jwt_handler.create_refresh_token(
|
|
username=user_data['username'],
|
|
user_id=user_data['user_id'],
|
|
server_id=request.new_server_id
|
|
)
|
|
|
|
return SwitchServerResponse(
|
|
success=True,
|
|
access_token=access_token,
|
|
refresh_token=refresh_token,
|
|
message="Server schimbat cu succes"
|
|
)
|
|
|
|
|
|
@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()
|
|
}
|