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:
2025-12-29 23:48:14 +02:00
parent 2a101f1ef5
commit c5e051ad80
378 changed files with 7566 additions and 73730 deletions

View 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

View 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'
]