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

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