Implement email-based 2FA authentication for Telegram bot with Oracle integration fixes

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 <noreply@anthropic.com>
This commit is contained in:
2025-11-11 12:00:46 +02:00
parent 1378ee1e6a
commit 706062dc0f
19 changed files with 2032 additions and 101 deletions

21
.gitignore vendored
View File

@@ -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
=*

View File

@@ -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

View File

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

View File

@@ -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
]

View File

@@ -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,

View File

@@ -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
# ============================================================================

View File

@@ -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.

View File

@@ -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

View File

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

View File

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

View File

@@ -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:

View File

@@ -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',

View File

@@ -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',
]

View File

@@ -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',

View File

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

View File

@@ -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"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
line-height: 1.6;
color: #333;
margin: 0;
padding: 0;
background-color: #f5f5f5;
}}
.container {{
max-width: 600px;
margin: 0 auto;
background: white;
}}
.header {{
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 40px 20px;
text-align: center;
}}
.header h1 {{
margin: 0;
font-size: 28px;
}}
.content {{
padding: 40px 20px;
}}
.code-box {{
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
border: 3px solid #667eea;
border-radius: 12px;
padding: 30px;
margin: 30px 0;
text-align: center;
}}
.code {{
font-size: 42px;
font-weight: bold;
letter-spacing: 12px;
color: #667eea;
font-family: 'Courier New', monospace;
display: block;
margin: 15px 0;
}}
.warning {{
background-color: #fff3cd;
border-left: 4px solid #ffc107;
padding: 15px;
margin: 20px 0;
border-radius: 4px;
}}
.footer {{
text-align: center;
color: #666;
font-size: 12px;
padding: 20px;
border-top: 1px solid #e0e0e0;
background-color: #f9f9f9;
}}
.button {{
display: inline-block;
padding: 12px 24px;
background-color: #667eea;
color: white;
text-decoration: none;
border-radius: 6px;
margin-top: 20px;
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>ROA2WEB</h1>
<p style="margin: 10px 0 0 0; opacity: 0.9;">Autentificare Telegram Bot</p>
</div>
<div class="content">
<p>Salut <strong>{username}</strong>,</p>
<p>Ai solicitat autentificarea în aplicația ROA2WEB Telegram Bot.</p>
<div class="code-box">
<p style="margin: 0; font-size: 14px; color: #666; font-weight: 500;">
Codul tău de autentificare:
</p>
<span class="code">{code}</span>
<p style="margin: 0; font-size: 12px; color: #888;">
Introdu acest cod în conversația Telegram
</p>
</div>
<div class="warning">
<strong>Important:</strong> Acest cod expiră în <strong>5 minute</strong>
și poate fi folosit o singură dată.
</div>
<p>După introducerea codului, vei fi solicitat să introduci parola ta Oracle.</p>
<hr style="border: none; border-top: 1px solid #e0e0e0; margin: 30px 0;">
<p style="font-size: 14px; color: #666;">
<strong>Nu ai solicitat acest cod?</strong><br>
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.
</p>
</div>
<div class="footer">
<p><strong>ROA2WEB</strong> - ERP Reports Application</p>
<p>Acest email a fost trimis automat. Te rugăm să nu răspunzi.</p>
<p style="margin-top: 10px; color: #999;">
© 2025 ROA2WEB. All rights reserved.
</p>
</div>
</div>
</body>
</html>
"""
# 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

View File

@@ -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

View File

@@ -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 &