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