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>
264 lines
8.3 KiB
Python
264 lines
8.3 KiB
Python
"""
|
|
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
|