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

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,