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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user