feat: Migrate to ultrathin monolith architecture
Consolidate 3 separate applications (reports-app, data-entry-app, telegram-bot) into a unified
architecture with single backend and frontend:
Backend Changes:
- Unified FastAPI backend at backend/ with modular structure
- Modules: reports, data_entry, telegram in backend/modules/
- Centralized config.py and main.py with all routers registered
- Single worker mode (--workers 1) for Telegram bot compatibility
- Shared Oracle connection pool and JWT authentication
- Unified requirements.txt and environment configuration
Frontend Changes:
- Single Vue.js SPA with module-based routing
- Unified frontend at src/ with modules in src/modules/{reports,data-entry}/
- Shared components and stores in src/shared/
- Error boundaries for module isolation
- Dual API proxy in Vite for module communication
Infrastructure:
- New unified startup scripts: start-prod.sh, start-test.sh, start-backend.sh
- Environment templates: .env.dev.example, .env.test.example, .env.prod.example
- Updated deployment scripts for Windows IIS
- Simplified SSH tunnel management
Documentation:
- Comprehensive CLAUDE.md with architecture overview
- Module-specific docs in docs/{data-entry,telegram}/
- Architecture decision records in docs/ARCHITECTURE-DECISIONS.md
- Deployment guides consolidated in deployment/windows/docs/
This migration reduces complexity, improves maintainability, and enables easier
deployment while maintaining all existing functionality.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
0
backend/modules/telegram/auth/__init__.py
Normal file
0
backend/modules/telegram/auth/__init__.py
Normal file
171
backend/modules/telegram/auth/email_auth.py
Normal file
171
backend/modules/telegram/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 backend.modules.telegram.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
|
||||
350
backend/modules/telegram/auth/linking.py
Normal file
350
backend/modules/telegram/auth/linking.py
Normal file
@@ -0,0 +1,350 @@
|
||||
"""
|
||||
Authentication and User Linking Logic
|
||||
|
||||
This module handles the linking process between Telegram users and Oracle ERP accounts.
|
||||
It manages authentication codes, verifies users through the backend API, and maintains
|
||||
user sessions with JWT tokens.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from telegram import User as TelegramUser
|
||||
|
||||
from backend.modules.telegram.db.operations import (
|
||||
get_user,
|
||||
create_or_update_user,
|
||||
link_user_to_oracle,
|
||||
update_user_tokens,
|
||||
verify_and_use_auth_code,
|
||||
is_user_linked
|
||||
)
|
||||
from backend.modules.telegram.api.client import get_backend_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def link_telegram_account(
|
||||
telegram_user: TelegramUser,
|
||||
auth_code: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Link a Telegram account to an Oracle ERP account using an authentication code.
|
||||
|
||||
Flow:
|
||||
1. Verify auth code in database (check exists, not used, not expired)
|
||||
2. Extract oracle_username from code
|
||||
3. Call backend API to verify user in Oracle and get JWT token
|
||||
4. Create/update Telegram user record
|
||||
5. Link user to Oracle account with JWT tokens
|
||||
6. Return success with user data
|
||||
|
||||
Args:
|
||||
telegram_user: Telegram User object from python-telegram-bot
|
||||
auth_code: 8-character authentication code from web frontend
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- success: True if linking succeeded
|
||||
- username: Oracle username
|
||||
- jwt_token: JWT access token
|
||||
- companies: List of companies user has access to
|
||||
OR None if linking failed
|
||||
|
||||
Example:
|
||||
result = await link_telegram_account(telegram_user, "ABC12345")
|
||||
if result:
|
||||
print(f"Linked to {result['username']}")
|
||||
else:
|
||||
print("Linking failed")
|
||||
"""
|
||||
try:
|
||||
telegram_user_id = telegram_user.id
|
||||
telegram_username = telegram_user.username
|
||||
first_name = telegram_user.first_name
|
||||
last_name = telegram_user.last_name
|
||||
|
||||
logger.info(
|
||||
f"Attempting to link Telegram user {telegram_user_id} "
|
||||
f"(@{telegram_username}) with code {auth_code}"
|
||||
)
|
||||
|
||||
# Step 1: Verify auth code
|
||||
code_data = await verify_and_use_auth_code(auth_code)
|
||||
|
||||
if not code_data:
|
||||
logger.warning(f"Invalid, expired, or already used auth code: {auth_code}")
|
||||
return None
|
||||
|
||||
oracle_username = code_data.get('oracle_username')
|
||||
logger.info(f"Auth code valid for Oracle user: {oracle_username}")
|
||||
|
||||
# Step 2: Create/update Telegram user record (basic info)
|
||||
user_created = await create_or_update_user(
|
||||
telegram_user_id=telegram_user_id,
|
||||
username=telegram_username,
|
||||
first_name=first_name,
|
||||
last_name=last_name
|
||||
)
|
||||
|
||||
if not user_created:
|
||||
logger.error(f"Failed to create/update Telegram user {telegram_user_id}")
|
||||
return None
|
||||
|
||||
# Step 3: Verify user in Oracle and get JWT token via backend API (auto-linking flow)
|
||||
backend_client = get_backend_client()
|
||||
async with backend_client:
|
||||
user_data = await backend_client.verify_user(
|
||||
oracle_username=oracle_username,
|
||||
linking_code=auth_code
|
||||
)
|
||||
|
||||
if not user_data or not user_data.get('success'):
|
||||
logger.error(f"Failed to verify Oracle user {oracle_username} via backend")
|
||||
return None
|
||||
|
||||
# Extract tokens and user info from response
|
||||
jwt_token = user_data.get('access_token')
|
||||
jwt_refresh_token = user_data.get('refresh_token', jwt_token)
|
||||
user_info = user_data.get('user', {})
|
||||
companies = user_info.get('companies', [])
|
||||
permissions = user_info.get('permissions', [])
|
||||
|
||||
# Token expiration (typically 30 minutes for access token)
|
||||
token_expires_at = datetime.now() + timedelta(minutes=30)
|
||||
|
||||
# Step 4: Link Telegram user to Oracle account
|
||||
linked = await link_user_to_oracle(
|
||||
telegram_user_id=telegram_user_id,
|
||||
oracle_username=oracle_username,
|
||||
jwt_token=jwt_token,
|
||||
jwt_refresh_token=jwt_refresh_token,
|
||||
token_expires_at=token_expires_at
|
||||
)
|
||||
|
||||
if not linked:
|
||||
logger.error(f"Failed to link user {telegram_user_id} to Oracle account")
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
f"Successfully linked Telegram user {telegram_user_id} "
|
||||
f"to Oracle user {oracle_username}"
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"telegram_user_id": telegram_user_id,
|
||||
"username": oracle_username,
|
||||
"jwt_token": jwt_token,
|
||||
"jwt_refresh_token": jwt_refresh_token,
|
||||
"companies": companies,
|
||||
"permissions": permissions,
|
||||
"linked_at": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error linking Telegram account: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
async def get_user_auth_data(telegram_user_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get authentication data for a linked Telegram user.
|
||||
|
||||
This function retrieves the user's Oracle account information and JWT tokens.
|
||||
If the token is expired, it automatically refreshes it.
|
||||
|
||||
Args:
|
||||
telegram_user_id: Telegram user ID
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- telegram_user_id: Telegram user ID
|
||||
- username: Oracle username
|
||||
- jwt_token: Valid JWT access token (refreshed if needed)
|
||||
- jwt_refresh_token: JWT refresh token
|
||||
- companies: List of companies (fetched if not cached)
|
||||
OR None if user is not linked or error occurred
|
||||
|
||||
Example:
|
||||
auth_data = await get_user_auth_data(12345)
|
||||
if auth_data:
|
||||
jwt = auth_data['jwt_token']
|
||||
# Use JWT for API calls
|
||||
"""
|
||||
try:
|
||||
# Get user from database
|
||||
user_data = await get_user(telegram_user_id)
|
||||
|
||||
if not user_data:
|
||||
logger.warning(f"User {telegram_user_id} not found in database")
|
||||
return None
|
||||
|
||||
if not user_data.get('oracle_username'):
|
||||
logger.warning(f"User {telegram_user_id} is not linked to Oracle account")
|
||||
return None
|
||||
|
||||
oracle_username = user_data['oracle_username']
|
||||
jwt_token = user_data['jwt_token']
|
||||
jwt_refresh_token = user_data['jwt_refresh_token']
|
||||
token_expires_at_str = user_data['token_expires_at']
|
||||
|
||||
# Parse token expiration
|
||||
token_expires_at = datetime.fromisoformat(token_expires_at_str) if token_expires_at_str else None
|
||||
|
||||
# Check if token is expired or about to expire (< 5 minutes remaining)
|
||||
token_expired = (
|
||||
token_expires_at is None or
|
||||
datetime.now() >= token_expires_at - timedelta(minutes=5)
|
||||
)
|
||||
|
||||
if token_expired:
|
||||
logger.info(f"Token expired for user {telegram_user_id}, refreshing...")
|
||||
|
||||
# Refresh token via backend API
|
||||
backend_client = get_backend_client()
|
||||
async with backend_client:
|
||||
new_token = await backend_client.refresh_token(jwt_refresh_token)
|
||||
|
||||
if new_token:
|
||||
# Update token in database
|
||||
new_expires_at = datetime.now() + timedelta(minutes=30)
|
||||
await update_user_tokens(
|
||||
telegram_user_id=telegram_user_id,
|
||||
jwt_token=new_token,
|
||||
jwt_refresh_token=jwt_refresh_token, # Keep same refresh token
|
||||
token_expires_at=new_expires_at
|
||||
)
|
||||
|
||||
jwt_token = new_token
|
||||
logger.info(f"Token refreshed for user {telegram_user_id}")
|
||||
else:
|
||||
logger.error(f"Failed to refresh token for user {telegram_user_id}")
|
||||
return None
|
||||
|
||||
# Fetch user companies (fresh from backend)
|
||||
backend_client = get_backend_client()
|
||||
async with backend_client:
|
||||
companies = await backend_client.get_user_companies(jwt_token)
|
||||
|
||||
return {
|
||||
"telegram_user_id": telegram_user_id,
|
||||
"username": oracle_username,
|
||||
"jwt_token": jwt_token,
|
||||
"jwt_refresh_token": jwt_refresh_token,
|
||||
"companies": companies
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting user auth data: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
async def check_user_linked(telegram_user_id: int) -> bool:
|
||||
"""
|
||||
Check if a Telegram user is linked to an Oracle account.
|
||||
|
||||
Args:
|
||||
telegram_user_id: Telegram user ID
|
||||
|
||||
Returns:
|
||||
bool: True if user is linked, False otherwise
|
||||
|
||||
Example:
|
||||
if await check_user_linked(12345):
|
||||
print("User is linked")
|
||||
else:
|
||||
print("User needs to link account")
|
||||
"""
|
||||
try:
|
||||
return await is_user_linked(telegram_user_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking if user is linked: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def get_user_companies(telegram_user_id: int) -> Optional[list]:
|
||||
"""
|
||||
Get list of companies a user has access to.
|
||||
|
||||
This is a convenience function that fetches user auth data and returns
|
||||
just the companies list.
|
||||
|
||||
Args:
|
||||
telegram_user_id: Telegram user ID
|
||||
|
||||
Returns:
|
||||
List of company dicts, or None if user not linked
|
||||
|
||||
Example:
|
||||
companies = await get_user_companies(12345)
|
||||
if companies:
|
||||
for company in companies:
|
||||
print(f"{company['id']}: {company['nume_firma']}")
|
||||
"""
|
||||
try:
|
||||
auth_data = await get_user_auth_data(telegram_user_id)
|
||||
|
||||
if auth_data:
|
||||
return auth_data.get('companies', [])
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting user companies: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def unlink_user(telegram_user_id: int) -> bool:
|
||||
"""
|
||||
Unlink a Telegram user from their Oracle account.
|
||||
|
||||
This removes the linking but keeps the Telegram user record.
|
||||
Used for account disconnection or security purposes.
|
||||
|
||||
Args:
|
||||
telegram_user_id: Telegram user ID
|
||||
|
||||
Returns:
|
||||
bool: True if successfully unlinked
|
||||
|
||||
Example:
|
||||
if await unlink_user(12345):
|
||||
print("Account unlinked")
|
||||
"""
|
||||
try:
|
||||
# Set Oracle username and tokens to NULL
|
||||
from backend.modules.telegram.db.database import DB_PATH
|
||||
import aiosqlite
|
||||
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
await db.execute("""
|
||||
UPDATE telegram_users
|
||||
SET oracle_username = NULL,
|
||||
jwt_token = NULL,
|
||||
jwt_refresh_token = NULL,
|
||||
token_expires_at = NULL,
|
||||
linked_at = NULL
|
||||
WHERE telegram_user_id = ?
|
||||
""", (telegram_user_id,))
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"User {telegram_user_id} unlinked from Oracle account")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error unlinking user {telegram_user_id}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# Export main functions
|
||||
__all__ = [
|
||||
'link_telegram_account',
|
||||
'get_user_auth_data',
|
||||
'check_user_linked',
|
||||
'get_user_companies',
|
||||
'unlink_user'
|
||||
]
|
||||
Reference in New Issue
Block a user