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:
21
.gitignore
vendored
21
.gitignore
vendored
@@ -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
|
||||
=*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
# ============================================================================
|
||||
|
||||
@@ -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.
|
||||
|
||||
171
reports-app/telegram-bot/app/auth/email_auth.py
Normal file
171
reports-app/telegram-bot/app/auth/email_auth.py
Normal 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
|
||||
667
reports-app/telegram-bot/app/bot/email_handlers.py
Normal file
667
reports-app/telegram-bot/app/bot/email_handlers.py
Normal 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"
|
||||
)
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
0
reports-app/telegram-bot/app/utils/__init__.py
Normal file
0
reports-app/telegram-bot/app/utils/__init__.py
Normal file
263
reports-app/telegram-bot/app/utils/email_service.py
Normal file
263
reports-app/telegram-bot/app/utils/email_service.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
90
start-dev.sh
90
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 &
|
||||
|
||||
Reference in New Issue
Block a user