From 706062dc0f396be53794334d7eb39912fa3f975c Mon Sep 17 00:00:00 2001 From: Marius Mutu Date: Tue, 11 Nov 2025 12:00:46 +0200 Subject: [PATCH] Implement email-based 2FA authentication for Telegram bot with Oracle integration fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit adds a complete email authentication flow for the Telegram bot, allowing users to login with email + password instead of web app linking codes. Includes critical bug fixes for Oracle integration. **New Features:** - Email-based 2FA authentication with 6-digit codes sent via SMTP - Backend endpoints: verify-email and login-with-email - ConversationHandler for email authentication flow in Telegram bot - Session token verification to prevent user ID spoofing - Rate limiting (5 attempts per 5 minutes) - Email code expiry (5 minutes) with automatic cleanup **Bug Fixes:** - Fixed Oracle column name: ACTIV → INACTIV (with inverted logic) - Fixed Oracle password verification: verificautilizator returns checksum, not user_id - Fixed username case sensitivity: Oracle usernames must be uppercase - Fixed SMTP connection: use start_tls parameter instead of manual STARTTLS - Added middleware exclusions for public email auth endpoints **Backend Changes:** - Added verify-email endpoint (public) in telegram.py - Added login-with-email endpoint (public) with rate limiting and session verification - Updated middleware exclusions in main.py and auth_middleware_wrapper.py - Added AUTH_SESSION_SECRET configuration for session token signing **Telegram Bot Changes:** - New modules: app/auth/email_auth.py, app/bot/email_handlers.py - New utilities: app/utils/email_service.py (SMTP email sending) - Updated handlers.py: ignore callbacks handled by ConversationHandler - Updated menus.py: show Login button for unauthenticated users - Updated API client: verify_email() and login_with_email() methods - Database: email_auth_codes table with cleanup task **Configuration:** - Added SMTP configuration to telegram-bot .env.example - Added AUTH_SESSION_SECRET to backend .env.example - Updated .gitignore: exclude temporary files (*.pid, *.checksum, test scripts) **Dependencies:** - Added aiosmtplib for async SMTP email sending 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .gitignore | 21 + reports-app/backend/.env.example | 5 + .../backend/app/auth_middleware_wrapper.py | 7 +- reports-app/backend/app/main.py | 2 + reports-app/backend/app/routers/telegram.py | 282 ++++++++ reports-app/telegram-bot/.env.example | 26 + reports-app/telegram-bot/app/api/client.py | 113 +++ .../telegram-bot/app/auth/email_auth.py | 171 +++++ .../telegram-bot/app/bot/email_handlers.py | 667 ++++++++++++++++++ reports-app/telegram-bot/app/bot/handlers.py | 85 ++- reports-app/telegram-bot/app/bot/menus.py | 70 +- reports-app/telegram-bot/app/db/__init__.py | 18 + reports-app/telegram-bot/app/db/database.py | 67 ++ reports-app/telegram-bot/app/db/operations.py | 222 ++++++ reports-app/telegram-bot/app/main.py | 21 +- .../telegram-bot/app/utils/__init__.py | 0 .../telegram-bot/app/utils/email_service.py | 263 +++++++ reports-app/telegram-bot/requirements.txt | 3 + start-dev.sh | 90 ++- 19 files changed, 2032 insertions(+), 101 deletions(-) create mode 100644 reports-app/telegram-bot/app/auth/email_auth.py create mode 100644 reports-app/telegram-bot/app/bot/email_handlers.py create mode 100644 reports-app/telegram-bot/app/utils/__init__.py create mode 100644 reports-app/telegram-bot/app/utils/email_service.py diff --git a/.gitignore b/.gitignore index 907adbe..5e96d29 100644 --- a/.gitignore +++ b/.gitignore @@ -444,3 +444,24 @@ yarn-error.log* # Allow proper test files (pytest, unittest) but exclude temporary test scripts !**/tests/test_*.py !**/test_*.py + +# ============================================================================ +# 🧹 TEMPORARY FILES FROM DEBUGGING SESSION - DO NOT COMMIT +# ============================================================================ + +# PID files from bot processes +*.pid +bot.pid + +# Requirements checksums (generated by start-dev.sh) +*.checksum + +# Temporary debugging/testing scripts in telegram-bot +reports-app/telegram-bot/check_db.py +reports-app/telegram-bot/test_email.py + +# Temporary planning documents +TELEGRAM_EMAIL_AUTH_PLAN*.md + +# Weird pip artifacts +=* diff --git a/reports-app/backend/.env.example b/reports-app/backend/.env.example index f09741e..a0e34c8 100644 --- a/reports-app/backend/.env.example +++ b/reports-app/backend/.env.example @@ -30,6 +30,11 @@ JWT_SECRET_KEY=GENERATE_STRONG_SECRET_IN_PRODUCTION ACCESS_TOKEN_EXPIRE_MINUTES=30 REFRESH_TOKEN_EXPIRE_DAYS=7 +# Session Security (Email Authentication) +# Must match telegram-bot AUTH_SESSION_SECRET for email login flow +# Generate with: python3 -c "import secrets; print(secrets.token_urlsafe(32))" +AUTH_SESSION_SECRET=your-secure-random-secret-here-min-32-chars + # Application Configuration API_HOST=0.0.0.0 API_PORT=8000 diff --git a/reports-app/backend/app/auth_middleware_wrapper.py b/reports-app/backend/app/auth_middleware_wrapper.py index ad5a40a..9669035 100644 --- a/reports-app/backend/app/auth_middleware_wrapper.py +++ b/reports-app/backend/app/auth_middleware_wrapper.py @@ -34,7 +34,12 @@ class FixedAuthenticationMiddleware(BaseHTTPMiddleware): print(f"[FIXED MIDDLEWARE] Processing path: {path}") # Verifică dacă path-ul trebuie exclus - excluded_paths = ["/docs", "/health", "/api/auth/login", "/redoc", "/openapi.json"] + excluded_paths = [ + "/docs", "/health", "/api/auth/login", "/redoc", "/openapi.json", + "/api/telegram/health", "/api/telegram/auth/verify-user", + "/api/telegram/auth/verify-email", "/api/telegram/auth/login-with-email", + "/api/telegram/auth/refresh-token" + ] is_excluded = (path == "/" or any(path.startswith(excluded) for excluded in excluded_paths)) print(f"[FIXED MIDDLEWARE] Checking exclusions for {path}") print(f"[FIXED MIDDLEWARE] Excluded paths: {excluded_paths}") diff --git a/reports-app/backend/app/main.py b/reports-app/backend/app/main.py index afa2321..51f39c8 100644 --- a/reports-app/backend/app/main.py +++ b/reports-app/backend/app/main.py @@ -301,6 +301,8 @@ app.add_middleware( excluded_paths=[ "/", "/docs", "/health", "/api/auth/login", "/redoc", "/openapi.json", "/api/telegram/auth/verify-user", # Public endpoint for Telegram bot + "/api/telegram/auth/verify-email", # Public endpoint for email verification (2FA flow) + "/api/telegram/auth/login-with-email", # Public endpoint for email + password login (2FA flow) "/api/telegram/auth/refresh-token", # Public endpoint for token refresh "/api/telegram/health" # Health check for Telegram router ] diff --git a/reports-app/backend/app/routers/telegram.py b/reports-app/backend/app/routers/telegram.py index 0ea6d2b..e00ef06 100644 --- a/reports-app/backend/app/routers/telegram.py +++ b/reports-app/backend/app/routers/telegram.py @@ -98,8 +98,105 @@ class ExportReportResponse(BaseModel): 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 @@ -473,6 +570,191 @@ async def refresh_token_endpoint(request: RefreshTokenRequest): ) +@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, diff --git a/reports-app/telegram-bot/.env.example b/reports-app/telegram-bot/.env.example index c56a41a..375fcb5 100644 --- a/reports-app/telegram-bot/.env.example +++ b/reports-app/telegram-bot/.env.example @@ -41,6 +41,32 @@ CLAUDE_API_KEY= # Docker: http://roa-backend:8000 BACKEND_URL=http://roa-backend:8000 +# ============================================================================ +# EMAIL AUTHENTICATION (SMTP) CONFIGURATION +# ============================================================================ +# Required for email-based 2FA authentication flow +# Users can login with email + password instead of web app linking + +# SMTP Server Configuration +SMTP_HOST=mail.romfast.ro +SMTP_PORT=587 +SMTP_USER=ups@romfast.ro +SMTP_PASSWORD=your_smtp_password_here +SMTP_FROM_EMAIL=ups@romfast.ro +SMTP_FROM_NAME=ROA2WEB +SMTP_USE_TLS=true + +# Email Sending Settings +EMAIL_MAX_RETRIES=3 +EMAIL_RETRY_DELAY=2.0 +EMAIL_CODE_EXPIRY_MINUTES=5 +EMAIL_CODE_LENGTH=6 +MAX_EMAIL_ATTEMPTS_PER_HOUR=3 + +# Session Security (must match backend AUTH_SESSION_SECRET) +# Generate with: python3 -c "import secrets; print(secrets.token_urlsafe(32))" +AUTH_SESSION_SECRET=your-secure-random-secret-here-min-32-chars + # ============================================================================ # DATABASE CONFIGURATION # ============================================================================ diff --git a/reports-app/telegram-bot/app/api/client.py b/reports-app/telegram-bot/app/api/client.py index ce15286..dac3a72 100644 --- a/reports-app/telegram-bot/app/api/client.py +++ b/reports-app/telegram-bot/app/api/client.py @@ -185,6 +185,119 @@ class BackendAPIClient: logger.error(f"Failed to refresh token: {e}") return None + async def verify_email(self, email: str) -> dict: + """ + Verify if email exists in Oracle database + + Args: + email: Email address to verify + + Returns: + dict with 'success' (bool), 'username' (str or None), and 'message' (str) + + Raises: + httpx.HTTPError: On network or HTTP errors + """ + try: + if not self.client: + self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT) + + response = await self.client.post( + "/api/telegram/auth/verify-email", + json={"email": email} + ) + response.raise_for_status() + return response.json() + + except httpx.HTTPStatusError as e: + logger.error(f"HTTP error verifying email {email}: {e.response.status_code}") + return { + "success": False, + "username": None, + "message": "Eroare la verificarea email-ului" + } + except Exception as e: + logger.error(f"Failed to verify email {email}: {e}") + return { + "success": False, + "username": None, + "message": "Eroare la verificarea email-ului" + } + + async def login_with_email( + self, + email: str, + password: str, + telegram_user_id: int, + session_token: str + ) -> dict: + """ + Login via email + password with session token + + Args: + email: User email address + password: Oracle password + telegram_user_id: Telegram user ID + session_token: Signed token from code validation + + Returns: + Login response with JWT tokens and user data + + Raises: + httpx.HTTPError: On network or HTTP errors + """ + try: + if not self.client: + self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT) + + response = await self.client.post( + "/api/telegram/auth/login-with-email", + json={ + "email": email, + "password": password, + "telegram_user_id": telegram_user_id, + "session_token": session_token + }, + timeout=30.0 # 30 seconds timeout + ) + + response.raise_for_status() + + data = response.json() + logger.info(f"Email login successful for user {telegram_user_id}") + + return data + + except httpx.HTTPStatusError as e: + logger.error(f"Email login HTTP error: {e.response.status_code} - {e.response.text}") + + # Parse error detail if available + try: + error_data = e.response.json() + return { + "success": False, + "message": error_data.get("detail", "Autentificare eșuată") + } + except: + return { + "success": False, + "message": "Autentificare eșuată" + } + + except httpx.TimeoutException: + logger.error("Email login timeout") + return { + "success": False, + "message": "Timeout. Te rugăm să încerci din nou." + } + + except Exception as e: + logger.error(f"Email login error: {e}", exc_info=True) + return { + "success": False, + "message": "Eroare de conexiune" + } + async def get_user_companies(self, jwt_token: str) -> List[Dict[str, Any]]: """ Get list of companies the user has access to. diff --git a/reports-app/telegram-bot/app/auth/email_auth.py b/reports-app/telegram-bot/app/auth/email_auth.py new file mode 100644 index 0000000..0070c7b --- /dev/null +++ b/reports-app/telegram-bot/app/auth/email_auth.py @@ -0,0 +1,171 @@ +""" +Email authentication logic with crypto-secure code generation +""" +import secrets +import re +import logging +from datetime import datetime, timedelta +from typing import Optional, Dict +from collections import defaultdict + +logger = logging.getLogger(__name__) + +# ============================================================================ +# RATE LIMITING (In-Memory) +# ============================================================================ +# NOTE: For production with multiple bot instances, migrate to Redis +# See "Optional Future Enhancements" section in plan + +_rate_limit_store: Dict[str, list] = defaultdict(list) + + +async def check_rate_limit( + identifier: str, + max_attempts: int = 3, + window_minutes: int = 60 +) -> bool: + """ + Check if identifier is within rate limit + + Args: + identifier: Email or telegram_user_id (as string) + max_attempts: Maximum attempts allowed + window_minutes: Time window in minutes + + Returns: + True if within limit (can proceed), False if exceeded + + NOTE: In-memory implementation - resets on bot restart + """ + now = datetime.now() + cutoff = now - timedelta(minutes=window_minutes) + + # Clean old attempts + _rate_limit_store[identifier] = [ + attempt for attempt in _rate_limit_store[identifier] + if attempt > cutoff + ] + + # Check limit + if len(_rate_limit_store[identifier]) >= max_attempts: + logger.warning(f"Rate limit exceeded for {identifier}") + return False + + # Add new attempt + _rate_limit_store[identifier].append(now) + return True + + +def clear_rate_limit(identifier: str) -> None: + """Clear rate limit for identifier (e.g., after successful auth)""" + if identifier in _rate_limit_store: + del _rate_limit_store[identifier] + logger.debug(f"Rate limit cleared for {identifier}") + + +# ============================================================================ +# CODE GENERATION (Crypto-Secure) +# ============================================================================ + +def generate_email_code() -> str: + """ + Generate crypto-secure 6-digit code + + Uses secrets module (not random) for cryptographic security + + Returns: + 6-digit string (000000 - 999999) + """ + # Generate 6-digit code using secrets (crypto-secure) + code = ''.join(secrets.choice('0123456789') for _ in range(6)) + + logger.debug(f"Generated email auth code (length: {len(code)})") + return code + + +# ============================================================================ +# EMAIL VALIDATION +# ============================================================================ + +def is_valid_email_format(email: str) -> bool: + """ + Validate email format (basic regex) + + Args: + email: Email address to validate + + Returns: + True if format is valid + """ + pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' + return bool(re.match(pattern, email)) + + +async def verify_email_in_oracle(email: str) -> Optional[str]: + """ + Verify email exists in Oracle UTILIZATORI table via backend API + + Args: + email: Email address to check + + Returns: + Oracle username if found and active, None otherwise + + NOTE: Uses backend API endpoint /api/telegram/auth/verify-email + """ + try: + from app.api.client import get_backend_client + + backend_client = get_backend_client() + + # Call backend API to verify email + response = await backend_client.verify_email(email) + + if response.get('success'): + username = response.get('username') + logger.info(f"Email verified via backend: {email} -> {username}") + return username + else: + logger.warning(f"Email not found or inactive: {email}") + return None + + except Exception as e: + logger.error(f"Error verifying email via backend: {e}", exc_info=True) + return None + + +# ============================================================================ +# SESSION TOKEN GENERATION (Prevent User ID Spoofing) +# ============================================================================ + +def generate_session_token(telegram_user_id: int, email: str) -> str: + """ + Generate signed session token for backend verification + + This prevents user ID spoofing attacks where malicious clients + could impersonate Telegram users by sending arbitrary user IDs + + Args: + telegram_user_id: Telegram user ID + email: Verified email address + + Returns: + Signed token (simple implementation - upgrade to JWT in future) + + NOTE: For production, use proper JWT signing with shared secret + """ + import hashlib + import os + + # Get secret from env (should match backend) + secret = os.getenv("AUTH_SESSION_SECRET", "change-me-in-production") + + # Create signature: HMAC-like hash + payload = f"{telegram_user_id}:{email}:{secret}" + signature = hashlib.sha256(payload.encode()).hexdigest()[:16] + + # Token format: user_id:email:signature + token = f"{telegram_user_id}:{email}:{signature}" + + logger.debug(f"Generated session token for user {telegram_user_id}") + return token diff --git a/reports-app/telegram-bot/app/bot/email_handlers.py b/reports-app/telegram-bot/app/bot/email_handlers.py new file mode 100644 index 0000000..36ea177 --- /dev/null +++ b/reports-app/telegram-bot/app/bot/email_handlers.py @@ -0,0 +1,667 @@ +""" +Telegram bot handlers for email-based authentication flow +""" +from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup +from telegram.ext import ( + ContextTypes, + ConversationHandler, + CommandHandler, + MessageHandler, + CallbackQueryHandler, + filters +) +import logging +from datetime import datetime + +from app.auth.email_auth import ( + is_valid_email_format, + verify_email_in_oracle, + generate_email_code, + generate_session_token, + check_rate_limit, + clear_rate_limit +) +from app.utils.email_service import get_email_service +from app.db.operations import ( + create_email_auth_code, + get_email_auth_code, + get_pending_email_code, + mark_email_code_used, + increment_failed_attempts, + delete_user_email_codes, + is_user_authenticated, + link_user_to_oracle, + create_or_update_user +) +from app.api.client import get_backend_client + +logger = logging.getLogger(__name__) + +# Conversation states +AWAITING_EMAIL, AWAITING_CODE, AWAITING_PASSWORD = range(3) + +# Constants +MAX_CODE_ATTEMPTS = 3 + + +# ============================================================================ +# ENTRY POINTS: /login command and action:login button +# ============================================================================ + +async def login_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """ + Handler pentru /login command + Oferă opțiuni de autentificare: Email sau Web App + """ + user = update.effective_user + + # Check dacă e deja autentificat + if await is_user_authenticated(user.id): + await update.message.reply_text( + "Ești deja autentificat.\n\n" + "Folosește:\n" + "• /companies - Vezi companiile tale\n" + "• /help - Comenzi disponibile\n" + "• /unlink - Deautentifică-te" + ) + return ConversationHandler.END + + # Check rate limiting (3 requests per hour) + if not await check_rate_limit(f"login_{user.id}", max_attempts=3, window_minutes=60): + await update.message.reply_text( + "Prea multe încercări de autentificare.\n\n" + "Te rugăm să aștepți 60 de minute înainte de a încerca din nou." + ) + return ConversationHandler.END + + # Afișează opțiuni de autentificare + keyboard = [ + [InlineKeyboardButton("Login cu Email + Parolă", callback_data="email_login")], + [InlineKeyboardButton("Login din Web App", callback_data="web_login_info")], + [InlineKeyboardButton("Anulează", callback_data="cancel")] + ] + reply_markup = InlineKeyboardMarkup(keyboard) + + await update.message.reply_text( + "**Alege metoda de autentificare:**\n\n" + "**Email + Parolă (2FA)**\n" + " • Primești cod pe email\n" + " • Introduci codul\n" + " • Introduci parola Oracle\n\n" + "**Web App**\n" + " • Login în aplicația web\n" + " • Generează cod de linking\n" + " • Trimite codul cu /start", + reply_markup=reply_markup, + parse_mode="Markdown" + ) + + return AWAITING_EMAIL + + +async def action_login_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): + """ + Handler pentru butonul Login din meniu (action:login) + Oferă opțiuni de autentificare: Email sau Web App + """ + query = update.callback_query + user = update.effective_user + + logger.info(f"[EMAIL_AUTH] action_login_callback triggered for user {user.id}") + + await query.answer() + + # Check dacă e deja autentificat + if await is_user_authenticated(user.id): + await query.edit_message_text( + "Ești deja autentificat.\n\n" + "Folosește:\n" + "• /companies - Vezi companiile tale\n" + "• /help - Comenzi disponibile\n" + "• /unlink - Deautentifică-te" + ) + return ConversationHandler.END + + # Check rate limiting (3 requests per hour) + if not await check_rate_limit(f"login_{user.id}", max_attempts=3, window_minutes=60): + await query.edit_message_text( + "Prea multe încercări de autentificare.\n\n" + "Te rugăm să aștepți 60 de minute înainte de a încerca din nou." + ) + return ConversationHandler.END + + # Afișează opțiuni de autentificare + keyboard = [ + [InlineKeyboardButton("Login cu Email + Parolă", callback_data="email_login")], + [InlineKeyboardButton("Login din Web App", callback_data="web_login_info")], + [InlineKeyboardButton("Anulează", callback_data="cancel")] + ] + reply_markup = InlineKeyboardMarkup(keyboard) + + await query.edit_message_text( + "**Alege metoda de autentificare:**\n\n" + "**Email + Parolă (2FA)**\n" + " • Primești cod pe email\n" + " • Introduci codul\n" + " • Introduci parola Oracle\n\n" + "**Web App**\n" + " • Login în aplicația web\n" + " • Generează cod de linking\n" + " • Trimite codul cu /start", + reply_markup=reply_markup, + parse_mode="Markdown" + ) + + return AWAITING_EMAIL + + +# ============================================================================ +# CALLBACK: Email Login +# ============================================================================ + +async def email_login_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Callback pentru butonul 'Login cu Email'""" + query = update.callback_query + user = update.effective_user + + logger.info(f"[EMAIL_AUTH] email_login_callback triggered for user {user.id}") + + await query.answer() + + await query.edit_message_text( + "**Autentificare prin Email + Parolă**\n\n" + "Te rugăm să introduci adresa ta de **email Oracle**:\n\n" + "Exemplu: nume.prenume@companie.ro\n\n" + "Vei primi un cod de 6 cifre pe email.\n\n" + "Scrie /cancel pentru a anula.", + parse_mode="Markdown" + ) + + return AWAITING_EMAIL + + +async def web_login_info_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Info despre web app login""" + query = update.callback_query + await query.answer() + + await query.edit_message_text( + "**Login din Web App**\n\n" + "Pentru această metodă:\n\n" + "1. Accesează aplicația web ROA2WEB\n" + "2. Autentifică-te cu username + parolă\n" + "3. Apasă butonul \"Link Telegram\"\n" + "4. Copiază codul generat (8 caractere)\n" + "5. Trimite-mi codul: /start ABC123XY\n\n" + "Vei fi autentificat automat.", + parse_mode="Markdown" + ) + + return ConversationHandler.END + + +# ============================================================================ +# STATE: AWAITING_EMAIL +# ============================================================================ + +async def receive_email(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handler pentru primirea email-ului""" + email = update.message.text.strip().lower() + user_id = update.effective_user.id + + # Validare format email + if not is_valid_email_format(email): + await update.message.reply_text( + "**Email invalid**\n\n" + "Te rugăm să introduci o adresă de email validă.\n\n" + "Format: nume@domeniu.ro", + parse_mode="Markdown" + ) + return AWAITING_EMAIL + + # Check for existing pending code + existing_code = await get_pending_email_code(user_id) + if existing_code: + # Delete old pending code + await delete_user_email_codes(user_id) + logger.info(f"Deleted existing pending code for user {user_id}") + + # Loading message + loading_msg = await update.message.reply_text("Verificare email...") + + try: + # Verifică email în Oracle + username = await verify_email_in_oracle(email) + + # IMPORTANT: Generic response to prevent email enumeration + # We always say "code sent" even if email doesn't exist + + if username: + # Email exists - generate and send code + code = generate_email_code() + + # Save code in database + code_saved = await create_email_auth_code( + code=code, + email=email, + username=username, + telegram_user_id=user_id, + expiry_minutes=5 + ) + + if not code_saved: + await loading_msg.edit_text( + "Eroare la salvarea codului. Te rugăm să încerci din nou cu /login" + ) + return ConversationHandler.END + + # Send email (async with retry) + email_service = get_email_service() + email_sent = await email_service.send_auth_code(email, code, username) + + if not email_sent: + logger.error(f"Failed to send email to {email}") + # Don't reveal this to user - they'll timeout naturally + + # ALWAYS show this message (prevent enumeration) + await loading_msg.edit_text( + "**Cod trimis**\n\n" + f"Am trimis un cod de 6 cifre pe **{email}**\n\n" + "Verifică:\n" + " • Inbox-ul\n" + " • Folderul Spam/Junk\n\n" + "Codul expiră în **5 minute**\n\n" + "Introdu codul aici sau apasă butonul de mai jos.", + parse_mode="Markdown", + reply_markup=InlineKeyboardMarkup([ + [InlineKeyboardButton("Retrimite Cod", callback_data=f"resend:{email}")], + [InlineKeyboardButton("Anulează", callback_data="cancel")] + ]) + ) + + # Save email in context for resend functionality + context.user_data['pending_email'] = email + context.user_data['pending_username'] = username + + return AWAITING_CODE + + except Exception as e: + logger.error(f"Error in receive_email: {e}", exc_info=True) + await loading_msg.edit_text( + "Eroare internă. Te rugăm să încerci din nou mai târziu." + ) + return ConversationHandler.END + + +# ============================================================================ +# STATE: AWAITING_CODE +# ============================================================================ + +async def receive_code(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handler pentru primirea codului din email""" + code = update.message.text.strip() + user_id = update.effective_user.id + + # Validare format cod (6 digits) + if not (code.isdigit() and len(code) == 6): + await update.message.reply_text( + "**Cod invalid**\n\n" + "Te rugăm să introduci cele **6 cifre** din email.\n\n" + "Format: 123456", + parse_mode="Markdown" + ) + return AWAITING_CODE + + # Verifică cod în DB + try: + code_data = await get_email_auth_code(code) + + if not code_data: + await update.message.reply_text( + "**Cod invalid sau expirat**\n\n" + "Te rugăm să:\n" + "• Verifici codul din email\n" + "• Sau reîncepi cu /login" + ) + return ConversationHandler.END + + # Verificări de securitate + + # 1. Check if already used + if code_data['used']: + await update.message.reply_text( + "**Cod deja folosit**\n\n" + "Fiecare cod poate fi folosit o singură dată.\n\n" + "Te rugăm să reîncepi cu /login" + ) + return ConversationHandler.END + + # 2. Check if expired + if datetime.now() > code_data['expires_at']: + await update.message.reply_text( + "**Cod expirat**\n\n" + "Codul era valabil 5 minute.\n\n" + "Te rugăm să reîncepi cu /login" + ) + return ConversationHandler.END + + # 3. Check if belongs to this user + if code_data['telegram_user_id'] != user_id: + logger.warning( + f"User {user_id} tried to use code belonging to " + f"user {code_data['telegram_user_id']}" + ) + await update.message.reply_text( + "**Cod invalid**" + ) + return ConversationHandler.END + + # 4. Check failed attempts (max 3) + if code_data['failed_attempts'] >= MAX_CODE_ATTEMPTS: + await update.message.reply_text( + "**Prea multe încercări greșite**\n\n" + "Te rugăm să reîncepi cu /login pentru un cod nou." + ) + return ConversationHandler.END + + # Cod valid - Marchează ca folosit + await mark_email_code_used(code) + + # Salvează date verificate în context + context.user_data['verified_username'] = code_data['oracle_username'] + context.user_data['verified_email'] = code_data['email'] + context.user_data['session_token'] = generate_session_token( + user_id, + code_data['email'] + ) + + await update.message.reply_text( + "**Cod validat cu succes**\n\n" + "Acum introdu **parola ta Oracle**:\n\n" + "**Important:**\n" + " • Parola va fi ștearsă automat\n" + " • Nu va fi vizibilă în chat\n" + " • Verificată direct în Oracle\n\n" + "Scrie /cancel pentru a anula.", + parse_mode="Markdown" + ) + + return AWAITING_PASSWORD + + except Exception as e: + logger.error(f"Error validating code: {e}", exc_info=True) + await update.message.reply_text( + "Eroare la validarea codului. Te rugăm să încerci din nou." + ) + return ConversationHandler.END + + +async def resend_code_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Retrimite codul pe email""" + query = update.callback_query + await query.answer("Retrimitem codul...") + + # Extract email from callback data + callback_data = query.data # Format: "resend:email@example.com" + if not callback_data.startswith("resend:"): + await query.edit_message_text("Eroare. Te rugăm să reîncepi cu /login") + return ConversationHandler.END + + email = callback_data.split(":", 1)[1] + user_id = update.effective_user.id + + # Check rate limiting for resend (max 2 per 10 minutes) + if not await check_rate_limit(f"resend_{user_id}", max_attempts=2, window_minutes=10): + await query.edit_message_text( + "Prea multe solicitări de retrimitere.\n\n" + "Te rugăm să aștepți 10 minute." + ) + return ConversationHandler.END + + # Get username from context or re-verify + username = context.user_data.get('pending_username') + + if not username: + username = await verify_email_in_oracle(email) + if not username: + await query.edit_message_text( + "Eroare. Te rugăm să reîncepi cu /login" + ) + return ConversationHandler.END + + # Delete old code and generate new one + await delete_user_email_codes(user_id) + + code = generate_email_code() + + # Save new code + await create_email_auth_code( + code=code, + email=email, + username=username, + telegram_user_id=user_id, + expiry_minutes=5 + ) + + # Send email + email_service = get_email_service() + await email_service.send_auth_code(email, code, username) + + await query.edit_message_text( + f"**Cod retrimis pe {email}**\n\n" + "Verifică inbox-ul (și spam).\n\n" + "Introdu codul aici.", + parse_mode="Markdown" + ) + + return AWAITING_CODE + + +# ============================================================================ +# STATE: AWAITING_PASSWORD +# ============================================================================ + +async def receive_password(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handler pentru primirea parolei Oracle""" + password = update.message.text.strip() + user_id = update.effective_user.id + + # Șterge IMEDIAT mesajul cu parola (securitate) + try: + await update.message.delete() + logger.info(f"Password message deleted for user {user_id}") + except Exception as e: + logger.warning(f"Could not delete password message: {e}") + + # Get verified data from context + username = context.user_data.get('verified_username') + email = context.user_data.get('verified_email') + session_token = context.user_data.get('session_token') + + if not all([username, email, session_token]): + await update.effective_chat.send_message( + "Sesiune expirată. Te rugăm să reîncepi cu /login" + ) + return ConversationHandler.END + + # Loading message + loading_msg = await update.effective_chat.send_message( + "Verificare credențiale în Oracle..." + ) + + try: + # Call backend endpoint pentru verificare parolă + JWT + backend_client = get_backend_client() + + response = await backend_client.login_with_email( + email=email, + password=password, + telegram_user_id=user_id, + session_token=session_token + ) + + if not response.get('success'): + await loading_msg.edit_text( + "**Credențiale invalide**\n\n" + "Parolă incorectă sau cont inactiv.\n\n" + "Te rugăm să reîncepi cu /login" + ) + return ConversationHandler.END + + # Success - Salvează user în telegram_users + # First create or update user record + await create_or_update_user( + telegram_user_id=user_id, + username=update.effective_user.username, + first_name=update.effective_user.first_name, + last_name=update.effective_user.last_name + ) + + # Then link to Oracle + from datetime import datetime, timedelta + token_expires_at = datetime.now() + timedelta(minutes=30) # Default expiry + + await link_user_to_oracle( + telegram_user_id=user_id, + oracle_username=response['username'], + jwt_token=response['access_token'], + jwt_refresh_token=response['refresh_token'], + token_expires_at=token_expires_at + ) + + # Clear rate limits on successful auth + clear_rate_limit(f"login_{user_id}") + clear_rate_limit(f"resend_{user_id}") + + # Delete loading message + try: + await loading_msg.delete() + except Exception: + pass + + # Show main menu with buttons (user is now authenticated) + from app.agent.session import get_session_manager + from app.bot.menus import create_main_menu, pad_message_for_wide_buttons + + # Get session and active company + session_manager = get_session_manager() + session = await session_manager.get_or_create_session(user_id) + company = session.get_active_company() + + company_name = company['name'] if company else None + company_cui = company.get('cui') if company else None + + # Create main menu keyboard + keyboard = create_main_menu( + company_name=company_name, + company_cui=company_cui, + is_authenticated=True, # Now authenticated + cache_enabled=True # Default enabled + ) + + # Success message with company info + companies_count = len(response.get('companies', [])) + + if company_name: + welcome_message = pad_message_for_wide_buttons( + f"**Autentificat cu succes**\n\n" + f"Bun venit, **{response['username']}**\n\n" + f"{company_name}" + ) + else: + welcome_message = pad_message_for_wide_buttons( + f"**Autentificat cu succes**\n\n" + f"Bun venit, **{response['username']}**\n\n" + f"Companii disponibile: **{companies_count}**\n\n" + f"Selectează o companie pentru a continua" + ) + + # Send menu with buttons + await update.effective_chat.send_message( + welcome_message, + reply_markup=keyboard, + parse_mode="Markdown" + ) + + # Clear sensitive data from context + context.user_data.clear() + + logger.info(f"User {user_id} authenticated successfully via email") + + return ConversationHandler.END + + except Exception as e: + logger.error(f"Error during password verification: {e}", exc_info=True) + await loading_msg.edit_text( + "Eroare la autentificare.\n\n" + "Te rugăm să încerci din nou cu /login" + ) + return ConversationHandler.END + + +# ============================================================================ +# CANCEL HANDLER +# ============================================================================ + +async def cancel_login(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Cancel conversation""" + context.user_data.clear() + + if update.message: + await update.message.reply_text( + "Autentificare anulată.\n\n" + "Folosește /login pentru a încerca din nou." + ) + elif update.callback_query: + await update.callback_query.edit_message_text( + "Autentificare anulată." + ) + + return ConversationHandler.END + + +async def conversation_timeout(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handler for conversation timeout""" + context.user_data.clear() + + await update.effective_chat.send_message( + "**Sesiune expirată**\n\n" + "Conversația de autentificare a expirat după 5 minute de inactivitate.\n\n" + "Te rugăm să reîncepi cu /login", + parse_mode="Markdown" + ) + + return ConversationHandler.END + + +# ============================================================================ +# CONVERSATION HANDLER SETUP +# ============================================================================ + +email_login_handler = ConversationHandler( + entry_points=[ + CommandHandler('login', login_command), + CallbackQueryHandler(action_login_callback, pattern='^action:login$'), + CallbackQueryHandler(email_login_callback, pattern='^email_login$') + ], + states={ + AWAITING_EMAIL: [ + MessageHandler(filters.TEXT & ~filters.COMMAND, receive_email) + ], + AWAITING_CODE: [ + MessageHandler(filters.TEXT & ~filters.COMMAND, receive_code), + CallbackQueryHandler(resend_code_callback, pattern='^resend:') + ], + AWAITING_PASSWORD: [ + MessageHandler(filters.TEXT & ~filters.COMMAND, receive_password) + ], + }, + fallbacks=[ + CommandHandler('cancel', cancel_login), + CallbackQueryHandler(cancel_login, pattern='^cancel$'), + CallbackQueryHandler(web_login_info_callback, pattern='^web_login_info$') + ], + per_message=False, # Track conversation per user, not per message + allow_reentry=True, # Allow starting new conversation even if previous one is active + name="email_login_conversation" +) diff --git a/reports-app/telegram-bot/app/bot/handlers.py b/reports-app/telegram-bot/app/bot/handlers.py index b0de287..280f72b 100644 --- a/reports-app/telegram-bot/app/bot/handlers.py +++ b/reports-app/telegram-bot/app/bot/handlers.py @@ -158,16 +158,24 @@ async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE): ) else: - # User not linked - show instructions with interactive buttons - keyboard = InlineKeyboardMarkup([ - [InlineKeyboardButton("Cum obtin codul de link?", callback_data="login_help")], - [InlineKeyboardButton("Am deja cod - Linkez contul", callback_data="login_prompt")] - ]) + # User not linked - show main menu with Login button + from app.bot.menus import create_main_menu, pad_message_for_wide_buttons + + keyboard = create_main_menu( + company_name=None, + company_cui=None, + is_authenticated=False, # Not authenticated - shows Login button + cache_enabled=None + ) + + welcome_text = pad_message_for_wide_buttons( + "**Bun venit la ROA2WEB Bot**\n\n" + "Pentru a incepe, te rog să te autentifici.\n\n" + "Selectează o companie pentru a continua" + ) await update.message.reply_text( - "**Bun venit la ROA2WEB Bot**\n\n" - "Pentru a incepe, conecteaza contul tau ROA2WEB.\n\n" - "Alege o optiune:", + welcome_text, reply_markup=keyboard, parse_mode=ParseMode.MARKDOWN ) @@ -1792,41 +1800,8 @@ async def handle_action_callback(query, telegram_user_id: int, callback_data: st parse_mode=ParseMode.MARKDOWN ) - elif action_type == "login": - # Prompt user to enter link code directly (same as login_prompt functionality) - from telegram import ForceReply - from app.bot.menus import pad_message_for_wide_buttons - - # Edit the current message with instructions - login_text = pad_message_for_wide_buttons( - "**Conectare Cont ROA2WEB**\n\n" - "Trimite-mi codul primit din aplicația web.\n\n" - "Poți trimite:\n" - "• Doar codul: ABC12XYZ\n" - "• Sau comanda: /start ABC12XYZ\n\n" - "Codul expiră în 15 minute." - ) - - # Buttons for help or cancel - keyboard = InlineKeyboardMarkup([ - [InlineKeyboardButton("Cum obțin codul?", callback_data="login_help")], - [InlineKeyboardButton("« Anulează", callback_data="action:menu")] - ]) - - await query.edit_message_text( - login_text, - reply_markup=keyboard, - parse_mode=ParseMode.MARKDOWN - ) - - # Send a follow-up message with ForceReply to prompt input - await query.message.reply_text( - "Scrie sau lipește codul aici:", - reply_markup=ForceReply( - selective=True, - input_field_placeholder="ABC12XYZ" - ) - ) + # NOTE: action:login is handled by email_login_handler ConversationHandler + # See app/bot/email_handlers.py for the complete email authentication flow async def handle_details_callback(query, telegram_user_id: int, callback_data: str): @@ -1997,10 +1972,32 @@ async def button_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): """ try: query = update.callback_query + callback_data = query.data + + # ========== IGNORE CALLBACKS HANDLED BY CONVERSATION HANDLER ========== + # These callbacks are managed by email_login_handler ConversationHandler + # Return immediately without answering to let ConversationHandler process them + conversation_patterns = [ + 'action:login', # Login button from menu + 'email_login', # Email login button + 'web_login_info', # Web app login info button + 'cancel', # Cancel button + ] + + # Check exact matches + if callback_data in conversation_patterns: + logger.info(f"[BUTTON_CALLBACK] Ignoring {callback_data} - handled by ConversationHandler") + return + + # Check prefix matches (e.g., resend:email@example.com) + if callback_data.startswith('resend:'): + logger.info(f"[BUTTON_CALLBACK] Ignoring {callback_data} - handled by ConversationHandler") + return + + # ========== PROCESS ALL OTHER CALLBACKS ========== await query.answer() telegram_user_id = update.effective_user.id - callback_data = query.data logger.info(f"Button callback: {callback_data} from user {telegram_user_id}") diff --git a/reports-app/telegram-bot/app/bot/menus.py b/reports-app/telegram-bot/app/bot/menus.py index 6c25c15..aa68782 100644 --- a/reports-app/telegram-bot/app/bot/menus.py +++ b/reports-app/telegram-bot/app/bot/menus.py @@ -207,42 +207,44 @@ def create_main_menu( """ keyboard = [] - # Row 1: Company selection (full width, single line - InlineKeyboardButton doesn't support multiline) - if company_name: - # Short company name for button (CUI and month will be shown in message text) - # Truncate long names to fit in button - max_length = 35 - display_name = company_name if len(company_name) <= max_length else company_name[:max_length-3] + "..." + # Only show financial menu if authenticated + if is_authenticated: + # Row 1: Company selection (full width, single line - InlineKeyboardButton doesn't support multiline) + if company_name: + # Short company name for button (CUI and month will be shown in message text) + # Truncate long names to fit in button + max_length = 35 + display_name = company_name if len(company_name) <= max_length else company_name[:max_length-3] + "..." - keyboard.append([ - InlineKeyboardButton( - f"{display_name}", - callback_data="menu:select_company" - ) - ]) - else: - keyboard.append([ - InlineKeyboardButton( - "Selectare Companie", - callback_data="menu:select_company" - ) - ]) + keyboard.append([ + InlineKeyboardButton( + f"{display_name}", + callback_data="menu:select_company" + ) + ]) + else: + keyboard.append([ + InlineKeyboardButton( + "Selectare Companie", + callback_data="menu:select_company" + ) + ]) - # Rows 2-4: Financial options (2 buttons per row, made wide by message text padding) - keyboard.extend([ - [ - InlineKeyboardButton("Sold Companie", callback_data="menu:sold"), - InlineKeyboardButton("Trezorerie Casa", callback_data="menu:casa") - ], - [ - InlineKeyboardButton("Trezorerie Banca", callback_data="menu:banca"), - InlineKeyboardButton("Sold Clienti", callback_data="menu:clienti") - ], - [ - InlineKeyboardButton("Sold Furnizori", callback_data="menu:furnizori"), - InlineKeyboardButton("Evolutie Incasari", callback_data="menu:evolutie") - ] - ]) + # Rows 2-4: Financial options (2 buttons per row, made wide by message text padding) + keyboard.extend([ + [ + InlineKeyboardButton("Sold Companie", callback_data="menu:sold"), + InlineKeyboardButton("Trezorerie Casa", callback_data="menu:casa") + ], + [ + InlineKeyboardButton("Trezorerie Banca", callback_data="menu:banca"), + InlineKeyboardButton("Sold Clienti", callback_data="menu:clienti") + ], + [ + InlineKeyboardButton("Sold Furnizori", callback_data="menu:furnizori"), + InlineKeyboardButton("Evolutie Incasari", callback_data="menu:evolutie") + ] + ]) # Row 5: Cache options (2 buttons per row, only if authenticated) if is_authenticated: diff --git a/reports-app/telegram-bot/app/db/__init__.py b/reports-app/telegram-bot/app/db/__init__.py index ddfe42d..7be9f02 100644 --- a/reports-app/telegram-bot/app/db/__init__.py +++ b/reports-app/telegram-bot/app/db/__init__.py @@ -12,6 +12,7 @@ from .database import ( get_db_connection, cleanup_expired_codes, cleanup_expired_sessions, + cleanup_expired_email_codes, get_database_stats, DB_PATH, ) @@ -24,11 +25,19 @@ from .operations import ( update_user_tokens, update_user_last_active, is_user_linked, + is_user_authenticated, # Auth code operations create_auth_code, get_auth_code, verify_and_use_auth_code, get_pending_codes_for_user, + # Email auth code operations + get_pending_email_code, + create_email_auth_code, + get_email_auth_code, + increment_failed_attempts, + mark_email_code_used, + delete_user_email_codes, # Session operations create_session, get_session, @@ -44,6 +53,7 @@ __all__ = [ 'get_db_connection', 'cleanup_expired_codes', 'cleanup_expired_sessions', + 'cleanup_expired_email_codes', 'get_database_stats', 'DB_PATH', # User operations @@ -53,11 +63,19 @@ __all__ = [ 'update_user_tokens', 'update_user_last_active', 'is_user_linked', + 'is_user_authenticated', # Auth code operations 'create_auth_code', 'get_auth_code', 'verify_and_use_auth_code', 'get_pending_codes_for_user', + # Email auth code operations + 'get_pending_email_code', + 'create_email_auth_code', + 'get_email_auth_code', + 'increment_failed_attempts', + 'mark_email_code_used', + 'delete_user_email_codes', # Session operations 'create_session', 'get_session', diff --git a/reports-app/telegram-bot/app/db/database.py b/reports-app/telegram-bot/app/db/database.py index cbcdb8f..2db991d 100644 --- a/reports-app/telegram-bot/app/db/database.py +++ b/reports-app/telegram-bot/app/db/database.py @@ -88,6 +88,22 @@ async def init_database() -> None: ) """) + # Create email_auth_codes table (email-based authentication) + await db.execute(""" + CREATE TABLE IF NOT EXISTS email_auth_codes ( + code TEXT PRIMARY KEY, + email TEXT NOT NULL, + oracle_username TEXT NOT NULL, + telegram_user_id INTEGER NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP NOT NULL, + used INTEGER DEFAULT 0, + used_at TIMESTAMP, + failed_attempts INTEGER DEFAULT 0, + FOREIGN KEY (telegram_user_id) REFERENCES telegram_users(telegram_user_id) + ) + """) + # Create indexes for better query performance await db.execute(""" CREATE INDEX IF NOT EXISTS idx_auth_codes_telegram_user @@ -109,6 +125,22 @@ async def init_database() -> None: ON telegram_sessions(expires_at) """) + # Create indexes for email_auth_codes table + await db.execute(""" + CREATE INDEX IF NOT EXISTS idx_email_auth_email + ON email_auth_codes(email) + """) + + await db.execute(""" + CREATE INDEX IF NOT EXISTS idx_email_auth_telegram_user + ON email_auth_codes(telegram_user_id) + """) + + await db.execute(""" + CREATE INDEX IF NOT EXISTS idx_email_auth_expires + ON email_auth_codes(expires_at) + """) + await db.commit() logger.info("Database initialized successfully") @@ -184,6 +216,40 @@ async def cleanup_expired_sessions() -> int: return 0 +async def cleanup_expired_email_codes() -> int: + """ + Delete expired and old used email codes from the database. + This should be called periodically (e.g., hourly). + + Returns: + int: Number of email codes deleted + """ + try: + async with aiosqlite.connect(DB_PATH) as db: + db.row_factory = aiosqlite.Row + # Delete expired codes or used codes older than 1 day + cursor = await db.execute(""" + DELETE FROM email_auth_codes + WHERE expires_at < ? + OR (used = 1 AND used_at < ?) + """, ( + datetime.now(), + datetime.now() - timedelta(days=1) + )) + + await db.commit() + deleted = cursor.rowcount + + if deleted > 0: + logger.info(f"Cleaned up {deleted} expired/old email auth codes") + + return deleted + + except Exception as e: + logger.error(f"Failed to cleanup email codes: {e}") + return 0 + + async def get_database_stats() -> dict: """ Get database statistics for monitoring. @@ -238,6 +304,7 @@ __all__ = [ 'init_database', 'cleanup_expired_codes', 'cleanup_expired_sessions', + 'cleanup_expired_email_codes', 'get_database_stats', 'DB_PATH', ] diff --git a/reports-app/telegram-bot/app/db/operations.py b/reports-app/telegram-bot/app/db/operations.py index 939bf20..e51830f 100644 --- a/reports-app/telegram-bot/app/db/operations.py +++ b/reports-app/telegram-bot/app/db/operations.py @@ -234,6 +234,45 @@ async def is_user_linked(telegram_user_id: int) -> bool: return False +async def is_user_authenticated(telegram_user_id: int) -> bool: + """ + Check if a user is authenticated (linked and has valid token). + + Args: + telegram_user_id: Telegram user ID + + Returns: + bool: True if user is authenticated + """ + try: + async with aiosqlite.connect(DB_PATH) as db: + db.row_factory = aiosqlite.Row + cursor = await db.execute(""" + SELECT oracle_username, jwt_token, token_expires_at + FROM telegram_users + WHERE telegram_user_id = ? + AND oracle_username IS NOT NULL + AND jwt_token IS NOT NULL + """, (telegram_user_id,)) + + row = await cursor.fetchone() + if not row: + return False + + # Check if token is expired (with some buffer) + if row[2]: # token_expires_at + expires_at = datetime.fromisoformat(row[2]) + # Token should have at least 5 minutes remaining + if expires_at < datetime.now() + timedelta(minutes=5): + return False + + return True + + except Exception as e: + logger.error(f"Failed to check if user {telegram_user_id} is authenticated: {e}") + return False + + # ============================================================================ # AUTHENTICATION CODES OPERATIONS # ============================================================================ @@ -377,6 +416,181 @@ async def get_pending_codes_for_user(telegram_user_id: int) -> List[Dict[str, An return [] +# ============================================================================ +# EMAIL AUTHENTICATION CODES OPERATIONS +# ============================================================================ + +async def get_pending_email_code( + telegram_user_id: int +) -> Optional[Dict]: + """ + Get pending (non-expired, non-used) email code for user + + Returns: + Code data dict or None if no pending code + """ + try: + async with aiosqlite.connect(DB_PATH) as db: + db.row_factory = aiosqlite.Row + cursor = await db.execute(""" + SELECT code, email, oracle_username, expires_at, failed_attempts + FROM email_auth_codes + WHERE telegram_user_id = ? + AND used = 0 + AND expires_at > ? + ORDER BY created_at DESC + LIMIT 1 + """, (telegram_user_id, datetime.now())) + + row = await cursor.fetchone() + + if row: + return { + 'code': row[0], + 'email': row[1], + 'oracle_username': row[2], + 'expires_at': datetime.fromisoformat(row[3]), + 'failed_attempts': row[4] + } + + return None + + except Exception as e: + logger.error(f"Failed to get pending email code: {e}") + return None + + +async def create_email_auth_code( + code: str, + email: str, + username: str, + telegram_user_id: int, + expiry_minutes: int = 5 +) -> bool: + """ + Create new email authentication code + + NOTE: Caller should check for existing pending codes first + """ + expires_at = datetime.now() + timedelta(minutes=expiry_minutes) + + try: + async with aiosqlite.connect(DB_PATH) as db: + db.row_factory = aiosqlite.Row + await db.execute(""" + INSERT INTO email_auth_codes + (code, email, oracle_username, telegram_user_id, expires_at) + VALUES (?, ?, ?, ?, ?) + """, (code, email, username, telegram_user_id, expires_at)) + + await db.commit() + + logger.info( + f"Email auth code created for user {telegram_user_id}, " + f"expires at {expires_at.isoformat()}" + ) + return True + + except Exception as e: + logger.error(f"Error creating email auth code: {e}", exc_info=True) + return False + + +async def get_email_auth_code(code: str) -> Optional[Dict]: + """Get email auth code details""" + try: + async with aiosqlite.connect(DB_PATH) as db: + db.row_factory = aiosqlite.Row + cursor = await db.execute(""" + SELECT code, email, oracle_username, telegram_user_id, + created_at, expires_at, used, used_at, failed_attempts + FROM email_auth_codes + WHERE code = ? + """, (code,)) + + row = await cursor.fetchone() + + if not row: + return None + + return { + 'code': row[0], + 'email': row[1], + 'oracle_username': row[2], + 'telegram_user_id': row[3], + 'created_at': datetime.fromisoformat(row[4]), + 'expires_at': datetime.fromisoformat(row[5]), + 'used': bool(row[6]), + 'used_at': datetime.fromisoformat(row[7]) if row[7] else None, + 'failed_attempts': row[8] + } + + except Exception as e: + logger.error(f"Failed to get email auth code: {e}") + return None + + +async def increment_failed_attempts(code: str) -> bool: + """Increment failed validation attempts for code""" + try: + async with aiosqlite.connect(DB_PATH) as db: + db.row_factory = aiosqlite.Row + await db.execute(""" + UPDATE email_auth_codes + SET failed_attempts = failed_attempts + 1 + WHERE code = ? + """, (code,)) + + await db.commit() + return True + + except Exception as e: + logger.error(f"Error incrementing failed attempts: {e}") + return False + + +async def mark_email_code_used(code: str) -> bool: + """Mark email code as used""" + try: + async with aiosqlite.connect(DB_PATH) as db: + db.row_factory = aiosqlite.Row + await db.execute(""" + UPDATE email_auth_codes + SET used = 1, used_at = ? + WHERE code = ? + """, (datetime.now(), code)) + + await db.commit() + + logger.info(f"Email auth code marked as used: {code}") + return True + + except Exception as e: + logger.error(f"Error marking email code as used: {e}") + return False + + +async def delete_user_email_codes(telegram_user_id: int) -> int: + """Delete all email codes for user (cleanup)""" + try: + async with aiosqlite.connect(DB_PATH) as db: + db.row_factory = aiosqlite.Row + cursor = await db.execute(""" + DELETE FROM email_auth_codes + WHERE telegram_user_id = ? + """, (telegram_user_id,)) + + await db.commit() + + deleted = cursor.rowcount + logger.info(f"Deleted {deleted} email codes for user {telegram_user_id}") + return deleted + + except Exception as e: + logger.error(f"Error deleting user email codes: {e}") + return 0 + + # ============================================================================ # SESSION OPERATIONS # ============================================================================ @@ -576,11 +790,19 @@ __all__ = [ 'update_user_tokens', 'update_user_last_active', 'is_user_linked', + 'is_user_authenticated', # Auth code operations 'create_auth_code', 'get_auth_code', 'verify_and_use_auth_code', 'get_pending_codes_for_user', + # Email auth code operations + 'get_pending_email_code', + 'create_email_auth_code', + 'get_email_auth_code', + 'increment_failed_attempts', + 'mark_email_code_used', + 'delete_user_email_codes', # Session operations 'create_session', 'get_session', diff --git a/reports-app/telegram-bot/app/main.py b/reports-app/telegram-bot/app/main.py index 5a895b5..3a36630 100644 --- a/reports-app/telegram-bot/app/main.py +++ b/reports-app/telegram-bot/app/main.py @@ -30,7 +30,12 @@ from telegram.ext import ( ) # Import database initialization -from app.db import init_database, cleanup_expired_codes, cleanup_expired_sessions +from app.db import ( + init_database, + cleanup_expired_codes, + cleanup_expired_sessions, + cleanup_expired_email_codes +) # Import bot handlers from app.bot.handlers import ( @@ -61,6 +66,9 @@ from app.bot.handlers import ( error_handler ) +# Import email authentication handler +from app.bot.email_handlers import email_login_handler + # Import internal API from app.internal_api import internal_api @@ -93,6 +101,9 @@ def create_telegram_application() -> Application: # Create application application = Application.builder().token(TELEGRAM_BOT_TOKEN).build() + # Register email authentication conversation handler (must be before other handlers) + application.add_handler(email_login_handler) + # Register essential command handlers application.add_handler(CommandHandler("start", start_command)) application.add_handler(CommandHandler("menu", menu_command)) @@ -186,7 +197,8 @@ async def startup(): logger.info("Cleaning up expired data...") expired_codes = await cleanup_expired_codes() expired_sessions = await cleanup_expired_sessions() - logger.info(f"✅ Cleanup complete: {expired_codes} codes, {expired_sessions} sessions removed") + expired_email_codes = await cleanup_expired_email_codes() + logger.info(f"✅ Cleanup complete: {expired_codes} codes, {expired_sessions} sessions, {expired_email_codes} email codes removed") except Exception as e: logger.warning(f"⚠️ Cleanup failed (non-critical): {e}") @@ -204,7 +216,7 @@ async def shutdown(): async def scheduled_cleanup(): """ Background task to periodically clean up expired data. - Runs every hour to remove expired auth codes and sessions. + Runs every hour to remove expired auth codes, sessions, and email codes. """ while True: try: @@ -212,7 +224,8 @@ async def scheduled_cleanup(): logger.info("🧹 Running scheduled cleanup...") expired_codes = await cleanup_expired_codes() expired_sessions = await cleanup_expired_sessions() - logger.info(f"✅ Scheduled cleanup: {expired_codes} codes, {expired_sessions} sessions removed") + expired_email_codes = await cleanup_expired_email_codes() + logger.info(f"✅ Scheduled cleanup: {expired_codes} codes, {expired_sessions} sessions, {expired_email_codes} email codes removed") except Exception as e: logger.error(f"❌ Error in scheduled cleanup: {e}") diff --git a/reports-app/telegram-bot/app/utils/__init__.py b/reports-app/telegram-bot/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/reports-app/telegram-bot/app/utils/email_service.py b/reports-app/telegram-bot/app/utils/email_service.py new file mode 100644 index 0000000..126a2be --- /dev/null +++ b/reports-app/telegram-bot/app/utils/email_service.py @@ -0,0 +1,263 @@ +""" +Async SMTP Email Service with retry logic and proper error handling +""" +import aiosmtplib +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +import os +import logging +from typing import Optional +import asyncio + +logger = logging.getLogger(__name__) + + +class EmailService: + """Async SMTP client for sending authentication codes""" + + def __init__(self): + self.smtp_host = os.getenv("SMTP_HOST", "mail.romfast.ro") + self.smtp_port = int(os.getenv("SMTP_PORT", "587")) + self.smtp_user = os.getenv("SMTP_USER") + self.smtp_password = os.getenv("SMTP_PASSWORD") + self.from_email = os.getenv("SMTP_FROM_EMAIL") + self.from_name = os.getenv("SMTP_FROM_NAME", "ROA2WEB") + self.use_tls = os.getenv("SMTP_USE_TLS", "true").lower() == "true" + + # Retry configuration + self.max_retries = int(os.getenv("EMAIL_MAX_RETRIES", "3")) + self.retry_delay = float(os.getenv("EMAIL_RETRY_DELAY", "2.0")) # seconds + + # Validate required config + if not all([self.smtp_user, self.smtp_password, self.from_email]): + raise ValueError("SMTP configuration incomplete. Check .env file.") + + async def send_auth_code( + self, + to_email: str, + code: str, + username: str + ) -> bool: + """ + Send authentication code via email with retry logic + + Args: + to_email: Recipient email address + code: 6-digit authentication code + username: Oracle username for personalization + + Returns: + True if email sent successfully (after retries if needed) + + Raises: + No exceptions - returns False on all failures + """ + subject = "Codul tău de autentificare ROA2WEB" + html_body = self._create_email_template(code, username) + + for attempt in range(1, self.max_retries + 1): + try: + await self._send_email(to_email, subject, html_body) + logger.info( + f"Email sent successfully to {to_email} " + f"(attempt {attempt}/{self.max_retries})" + ) + return True + + except aiosmtplib.SMTPException as e: + logger.warning( + f"SMTP error on attempt {attempt}/{self.max_retries}: {e}" + ) + if attempt < self.max_retries: + # Exponential backoff: 2s, 4s, 8s + delay = self.retry_delay * (2 ** (attempt - 1)) + logger.info(f"Retrying in {delay}s...") + await asyncio.sleep(delay) + else: + logger.error(f"Failed to send email to {to_email} after {self.max_retries} attempts") + + except Exception as e: + logger.error(f"Unexpected error sending email: {e}", exc_info=True) + return False + + return False + + async def _send_email( + self, + to_email: str, + subject: str, + html_body: str + ) -> None: + """ + Internal async SMTP sender + + Raises: + aiosmtplib.SMTPException: On SMTP errors + """ + message = MIMEMultipart("alternative") + message["From"] = f"{self.from_name} <{self.from_email}>" + message["To"] = to_email + message["Subject"] = subject + + # Attach HTML body + html_part = MIMEText(html_body, "html", "utf-8") + message.attach(html_part) + + # Send via async SMTP with STARTTLS + # Using start_tls parameter for automatic STARTTLS handling + smtp = aiosmtplib.SMTP( + hostname=self.smtp_host, + port=self.smtp_port, + start_tls=self.use_tls, # Use start_tls instead of use_tls + timeout=30 + ) + + try: + await smtp.connect() + await smtp.login(self.smtp_user, self.smtp_password) + await smtp.send_message(message) + finally: + try: + await smtp.quit() + except: + pass + + def _create_email_template(self, code: str, username: str) -> str: + """Generate HTML email template""" + return f""" + + + + + + + + +
+
+

ROA2WEB

+

Autentificare Telegram Bot

+
+ +
+

Salut {username},

+ +

Ai solicitat autentificarea în aplicația ROA2WEB Telegram Bot.

+ +
+

+ Codul tău de autentificare: +

+ {code} +

+ Introdu acest cod în conversația Telegram +

+
+ +
+ Important: Acest cod expiră în 5 minute + și poate fi folosit o singură dată. +
+ +

După introducerea codului, vei fi solicitat să introduci parola ta Oracle.

+ +
+ +

+ Nu ai solicitat acest cod?
+ Dacă nu ai inițiat această autentificare, poți ignora acest email în siguranță. + Nimeni nu va avea acces la contul tău fără parola ta Oracle. +

+
+ + +
+ + + """ + + +# Singleton instance +_email_service: Optional[EmailService] = None + + +def get_email_service() -> EmailService: + """Get or create singleton email service instance""" + global _email_service + if _email_service is None: + _email_service = EmailService() + return _email_service diff --git a/reports-app/telegram-bot/requirements.txt b/reports-app/telegram-bot/requirements.txt index 5ea05fc..d49ebea 100644 --- a/reports-app/telegram-bot/requirements.txt +++ b/reports-app/telegram-bot/requirements.txt @@ -10,6 +10,9 @@ pydantic>=2.5.0 # Environment Variables python-dotenv>=1.0.0 +# Email (SMTP) +aiosmtplib>=3.0.0 + # SQLite Async Database (STANDALONE) aiosqlite>=0.19.0 diff --git a/start-dev.sh b/start-dev.sh index 2d9eef1..dbe2f92 100644 --- a/start-dev.sh +++ b/start-dev.sh @@ -39,6 +39,76 @@ check_port() { fi } +# Function to check if requirements.txt has changed +check_requirements_changed() { + local requirements_file=$1 + local checksum_file="${requirements_file}.checksum" + + if [ ! -f "$requirements_file" ]; then + return 1 # Requirements file doesn't exist + fi + + # Calculate current checksum + current_checksum=$(md5sum "$requirements_file" | cut -d' ' -f1) + + # Check if checksum file exists and compare + if [ -f "$checksum_file" ]; then + stored_checksum=$(cat "$checksum_file") + if [ "$current_checksum" = "$stored_checksum" ]; then + return 1 # No change + fi + fi + + return 0 # Changed or first time +} + +# Function to save requirements checksum +save_requirements_checksum() { + local requirements_file=$1 + local checksum_file="${requirements_file}.checksum" + + if [ -f "$requirements_file" ]; then + md5sum "$requirements_file" | cut -d' ' -f1 > "$checksum_file" + fi +} + +# Function to install or update Python dependencies +install_python_dependencies() { + local project_name=$1 + local venv_path=$2 + local requirements_file=$3 + + # Check if venv exists + if [ ! -d "$venv_path" ]; then + print_message "Creating Python virtual environment for $project_name..." + python3 -m venv "$venv_path" + fi + + # Activate virtual environment + source "$venv_path/bin/activate" + + # Check if requirements have changed or dependencies are missing + local should_install=false + + if check_requirements_changed "$requirements_file"; then + print_message "Requirements changed for $project_name - updating dependencies..." + should_install=true + elif ! python -c "import sys; import importlib; [importlib.import_module(line.split('>=')[0].split('==')[0]) for line in open('$requirements_file').read().splitlines() if line and not line.startswith('#')]" 2>/dev/null; then + print_message "Missing dependencies detected for $project_name - installing..." + should_install=true + fi + + if [ "$should_install" = true ]; then + print_message "Installing/updating $project_name dependencies..." + pip install --upgrade pip > /dev/null 2>&1 + pip install -r "$requirements_file" + save_requirements_checksum "$requirements_file" + print_success "$project_name dependencies installed/updated successfully" + else + print_message "$project_name dependencies are up to date" + fi +} + # Function to cleanup processes on exit cleanup() { print_message "Stopping services..." @@ -284,15 +354,7 @@ start_service() { fi cd reports-app/backend/ - if [ ! -d "venv" ]; then - print_message "Creating Python virtual environment..." - python3 -m venv venv - fi - source venv/bin/activate - if ! python -c "import fastapi, uvicorn" 2>/dev/null; then - print_message "Installing backend dependencies..." - pip install -r requirements.txt - fi + install_python_dependencies "Backend" "venv" "requirements.txt" print_message "Starting uvicorn server..." # NOTE: --reload disabled for cache to work properly (global variables issue) @@ -375,15 +437,7 @@ start_service() { exit 1 fi - if [ ! -d "venv" ]; then - print_message "Creating Python virtual environment for Telegram bot..." - python3 -m venv venv - fi - source venv/bin/activate - if ! python -c "import telegram" 2>/dev/null; then - print_message "Installing Telegram bot dependencies..." - pip install -r requirements.txt - fi + install_python_dependencies "Telegram Bot" "venv" "requirements.txt" print_message "Starting Telegram bot..." nohup python -m app.main > /tmp/roa2web_telegram.log 2>&1 &