feat: production deployment - full update with providers, admin, glossaries, pricing, tests
Major changes across backend, frontend, infrastructure: - Provider system with model selection (Google, DeepL, OpenAI, Ollama, Google Cloud) - Admin panel: user management, pricing, settings - Glossary system with CSV import/export - Subscription and tier quota management - Security hardening (rate limiting, API key auth, path traversal fixes) - Docker compose for dev, prod, and IONOS deployment - Alembic migrations for new tables - Frontend: dashboard, pricing page, landing page, i18n (en/fr) - Test suite and verification scripts Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,8 +2,9 @@
|
||||
Authentication service with JWT tokens and password hashing
|
||||
|
||||
This service provides user authentication with automatic backend selection:
|
||||
- If DATABASE_URL is configured: Uses PostgreSQL database
|
||||
- Otherwise: Falls back to JSON file storage (development mode)
|
||||
- When SQLAlchemy models are available: uses the same SQLite/PostgreSQL DB as the app.
|
||||
- Otherwise: falls back to JSON file storage (data/users.json).
|
||||
- If the DB is enabled but a user exists only in the legacy JSON file, lookups fall back to JSON.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -37,27 +38,25 @@ except ImportError:
|
||||
PASSLIB_AVAILABLE = False
|
||||
logger.warning("passlib not installed. Using SHA256 fallback for password hashing.")
|
||||
|
||||
# Check if database is configured
|
||||
# User storage: same SQLAlchemy DB as the rest of the app (SQLite by default, or PostgreSQL).
|
||||
# Previously only DATABASE_URL=postgresql* enabled the DB, so dev (SQLite) used data/users.json
|
||||
# while registrations could end up in translate.db — logins then failed to find users.
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "")
|
||||
USE_DATABASE = bool(DATABASE_URL and DATABASE_URL.startswith("postgresql"))
|
||||
USE_DATABASE = False
|
||||
DATABASE_AVAILABLE = False
|
||||
|
||||
if USE_DATABASE:
|
||||
try:
|
||||
from database.repositories import UserRepository
|
||||
from database.connection import get_sync_session, init_db as _init_db
|
||||
from database import models as db_models
|
||||
try:
|
||||
from database.repositories import UserRepository
|
||||
from database.connection import get_sync_session, init_db as _init_db
|
||||
from database import models as db_models
|
||||
|
||||
DATABASE_AVAILABLE = True
|
||||
logger.info("Database backend enabled for authentication")
|
||||
except ImportError as e:
|
||||
DATABASE_AVAILABLE = False
|
||||
USE_DATABASE = False
|
||||
logger.warning(f"Database modules not available: {e}. Using JSON storage.")
|
||||
else:
|
||||
DATABASE_AVAILABLE = False
|
||||
DATABASE_AVAILABLE = True
|
||||
USE_DATABASE = True
|
||||
logger.info(
|
||||
"Using JSON file storage for authentication (DATABASE_URL not configured)"
|
||||
"Database backend enabled for authentication (SQLite or PostgreSQL, same as DATABASE_URL / SQLITE_PATH)"
|
||||
)
|
||||
except ImportError as e:
|
||||
logger.warning(f"Database modules not available: {e}. Using JSON storage.")
|
||||
|
||||
from models.subscription import User, UserCreate, PlanType, SubscriptionStatus, PLANS
|
||||
|
||||
@@ -84,29 +83,16 @@ USERS_FILE.parent.mkdir(exist_ok=True)
|
||||
# Token blocklist: jti → expiry timestamp (Unix).
|
||||
# Uses Redis when available (persistent across restarts), falls back to in-memory.
|
||||
_revoked_jtis: dict[str, float] = {}
|
||||
_redis_blocklist_client = None
|
||||
|
||||
|
||||
def _get_blocklist_redis():
|
||||
"""Return Redis client for token blocklist, or None if unavailable."""
|
||||
global _redis_blocklist_client
|
||||
if _redis_blocklist_client is not None:
|
||||
return _redis_blocklist_client if _redis_blocklist_client is not False else None
|
||||
redis_url = os.getenv("REDIS_URL", "")
|
||||
if not redis_url:
|
||||
_redis_blocklist_client = False
|
||||
return None
|
||||
try:
|
||||
import redis as redis_lib
|
||||
client = redis_lib.from_url(redis_url, decode_responses=True)
|
||||
client.ping()
|
||||
_redis_blocklist_client = client
|
||||
"""Return Redis client for token blocklist from core.redis, or None if unavailable."""
|
||||
from core.redis import get_sync_redis
|
||||
|
||||
client = get_sync_redis()
|
||||
if client:
|
||||
logger.info("Token blocklist using Redis (persistent across restarts)")
|
||||
return client
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis unavailable for token blocklist, using in-memory: {e}")
|
||||
_redis_blocklist_client = False
|
||||
return None
|
||||
return client
|
||||
|
||||
|
||||
def revoke_token_jti(jti: str, expires_at: float) -> None:
|
||||
@@ -267,6 +253,22 @@ def save_users(users: Dict[str, Dict]):
|
||||
json.dump(users, f, indent=2, default=str)
|
||||
|
||||
|
||||
def _get_user_from_json_file_by_email(email: str) -> Optional[User]:
|
||||
"""Legacy users.json when DB is primary but account was never migrated."""
|
||||
users = load_users()
|
||||
for user_data in users.values():
|
||||
if user_data.get("email", "").lower() == email.lower():
|
||||
return User(**user_data)
|
||||
return None
|
||||
|
||||
|
||||
def _get_user_from_json_file_by_id(user_id: str) -> Optional[User]:
|
||||
users = load_users()
|
||||
if user_id in users:
|
||||
return User(**users[user_id])
|
||||
return None
|
||||
|
||||
|
||||
def _db_user_to_model(db_user) -> User:
|
||||
"""Convert database user model to Pydantic User model"""
|
||||
return User(
|
||||
@@ -300,13 +302,21 @@ def get_user_by_email(email: str) -> Optional[User]:
|
||||
if USE_DATABASE and DATABASE_AVAILABLE:
|
||||
from database.connection import get_sync_session
|
||||
from database.repositories import UserRepository
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
with get_sync_session() as session:
|
||||
repo = UserRepository(session)
|
||||
db_user = repo.get_by_email(email)
|
||||
if db_user:
|
||||
return _db_user_to_model(db_user)
|
||||
return None
|
||||
try:
|
||||
with get_sync_session() as session:
|
||||
repo = UserRepository(session)
|
||||
db_user = repo.get_by_email(email)
|
||||
if db_user:
|
||||
return _db_user_to_model(db_user)
|
||||
return _get_user_from_json_file_by_email(email)
|
||||
except OperationalError as e:
|
||||
logger.warning(
|
||||
"User lookup by email failed (DB): %s; trying users.json",
|
||||
e,
|
||||
)
|
||||
return _get_user_from_json_file_by_email(email)
|
||||
else:
|
||||
users = load_users()
|
||||
for user_data in users.values():
|
||||
@@ -320,13 +330,21 @@ def get_user_by_id(user_id: str) -> Optional[User]:
|
||||
if USE_DATABASE and DATABASE_AVAILABLE:
|
||||
from database.connection import get_sync_session
|
||||
from database.repositories import UserRepository
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
with get_sync_session() as session:
|
||||
repo = UserRepository(session)
|
||||
db_user = repo.get_by_id(user_id)
|
||||
if db_user:
|
||||
return _db_user_to_model(db_user)
|
||||
return None
|
||||
try:
|
||||
with get_sync_session() as session:
|
||||
repo = UserRepository(session)
|
||||
db_user = repo.get_by_id(user_id)
|
||||
if db_user:
|
||||
return _db_user_to_model(db_user)
|
||||
return _get_user_from_json_file_by_id(user_id)
|
||||
except OperationalError as e:
|
||||
logger.warning(
|
||||
"User lookup by id failed (DB): %s; trying users.json",
|
||||
e,
|
||||
)
|
||||
return _get_user_from_json_file_by_id(user_id)
|
||||
else:
|
||||
users = load_users()
|
||||
if user_id in users:
|
||||
@@ -386,6 +404,32 @@ def authenticate_user(email: str, password: str) -> Optional[User]:
|
||||
return user
|
||||
|
||||
|
||||
def admin_set_user_password(user_id: str, plain_password: str) -> Optional[User]:
|
||||
"""
|
||||
Définit un nouveau mot de passe (haché) pour un utilisateur.
|
||||
Couvre la base SQLAlchemy et le fichier legacy data/users.json.
|
||||
"""
|
||||
if len(plain_password) < 8:
|
||||
raise ValueError("Le mot de passe doit contenir au moins 8 caractères")
|
||||
hp = hash_password(plain_password)
|
||||
if USE_DATABASE and DATABASE_AVAILABLE:
|
||||
from database.connection import get_sync_session
|
||||
from database.repositories import UserRepository
|
||||
|
||||
with get_sync_session() as session:
|
||||
repo = UserRepository(session)
|
||||
db_user = repo.update(user_id, hashed_password=hp)
|
||||
if db_user:
|
||||
return _db_user_to_model(db_user)
|
||||
users = load_users()
|
||||
if user_id in users:
|
||||
users[user_id]["password_hash"] = hp
|
||||
users[user_id]["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
save_users(users)
|
||||
return User(**users[user_id])
|
||||
return None
|
||||
|
||||
|
||||
def update_user(user_id: str, updates: Dict[str, Any]) -> Optional[User]:
|
||||
"""Update a user's data"""
|
||||
if USE_DATABASE and DATABASE_AVAILABLE:
|
||||
@@ -397,7 +441,13 @@ def update_user(user_id: str, updates: Dict[str, Any]) -> Optional[User]:
|
||||
db_user = repo.update(user_id, **updates)
|
||||
if db_user:
|
||||
return _db_user_to_model(db_user)
|
||||
return None
|
||||
users = load_users()
|
||||
if user_id not in users:
|
||||
return None
|
||||
users[user_id].update(updates)
|
||||
users[user_id]["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
save_users(users)
|
||||
return User(**users[user_id])
|
||||
else:
|
||||
users = load_users()
|
||||
if user_id not in users:
|
||||
@@ -562,6 +612,40 @@ def get_user_by_api_key(api_key: str) -> Optional[User]:
|
||||
return None
|
||||
|
||||
|
||||
def get_or_create_google_user(
|
||||
email: str, name: str, avatar_url: Optional[str] = None
|
||||
) -> User:
|
||||
"""Find or create a user via Google OAuth (email as identifier)."""
|
||||
if USE_DATABASE and DATABASE_AVAILABLE:
|
||||
from services.auth_service_db import get_or_create_google_user as _db_impl
|
||||
|
||||
db_user = _db_impl(email=email, name=name, avatar_url=avatar_url)
|
||||
return _db_user_to_model(db_user)
|
||||
|
||||
existing = get_user_by_email(email)
|
||||
if existing:
|
||||
updates: Dict[str, Any] = {}
|
||||
if avatar_url and not existing.avatar_url:
|
||||
updates["avatar_url"] = avatar_url
|
||||
if not existing.email_verified:
|
||||
updates["email_verified"] = True
|
||||
if updates:
|
||||
updated = update_user(existing.id, updates)
|
||||
return updated if updated else existing
|
||||
return existing
|
||||
|
||||
# Password must satisfy UserCreate validators (length, upper, lower, digit)
|
||||
pw = f"Aa1{secrets.token_urlsafe(24)}"
|
||||
user_create = UserCreate(email=email, name=name, password=pw)
|
||||
user = create_user(user_create)
|
||||
update_user(
|
||||
user.id,
|
||||
{"email_verified": True, "avatar_url": avatar_url},
|
||||
)
|
||||
refreshed = get_user_by_id(user.id)
|
||||
return refreshed if refreshed else user
|
||||
|
||||
|
||||
def init_database():
|
||||
"""Initialize the database (call on application startup)"""
|
||||
if USE_DATABASE and DATABASE_AVAILABLE:
|
||||
|
||||
Reference in New Issue
Block a user