From cca6564dad4161b5fa33adb2070f4da37f066fcd Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Sat, 16 May 2026 09:16:23 +0000 Subject: [PATCH] feat(auth): JWTManager, get_current_user, require_admin, require_project_owner decorators Co-Authored-By: Claude Sonnet 4.6 --- backend/app/__init__.py | 127 +++++++++++++++++++++--------------- backend/app/api/__init__.py | 18 ++--- backend/app/api/admin.py | 0 backend/app/api/users.py | 0 4 files changed, 83 insertions(+), 62 deletions(-) create mode 100644 backend/app/api/admin.py create mode 100644 backend/app/api/users.py diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 77db4691..9dd8df8e 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -1,24 +1,30 @@ -""" -MiroFish Backend - Flask application factory -""" - +"""MiroFish Backend - Flask application factory""" import os import warnings +from functools import wraps -# Suppress multiprocessing resource_tracker warnings (from third-party libraries like transformers) -# Must be set before all other imports warnings.filterwarnings("ignore", message=".*resource_tracker.*") -import jwt from flask import Flask, request, jsonify from flask_cors import CORS -from flask_jwt_extended import JWTManager +from flask_jwt_extended import JWTManager, verify_jwt_in_request, get_jwt_identity from .config import Config from .utils.logger import setup_logger, get_logger -# Rutes públiques que no requereixen token JWT -_PUBLIC_PATHS = {'/health', '/api/auth/login'} +# Rutes públiques (sense JWT requerit) +_PUBLIC_PATHS = { + '/health', + '/api/auth/login', + '/api/auth/logout', + '/api/auth/forgot-password', + '/api/auth/reset-password', + '/api/auth/set-password', +} +_PUBLIC_PREFIXES = ( + '/api/auth/invitation/', + '/api/auth/reset-password/', +) def create_app(config_class=Config): @@ -38,18 +44,13 @@ def create_app(config_class=Config): from .storage import create_storage_service app.extensions['storage'] = create_storage_service() - # Inicialitzar JWT + # flask-jwt-extended JWTManager(app) - # Configure JSON encoding: ensure non-ASCII characters are output directly (not as \uXXXX) - # Flask >= 2.3 uses app.json.ensure_ascii; older versions use JSON_AS_ASCII config if hasattr(app, 'json') and hasattr(app.json, 'ensure_ascii'): app.json.ensure_ascii = False - # Set up logging logger = setup_logger('mirofish') - - # Only log startup info in the reloader subprocess (avoids double-printing in debug mode) is_reloader_process = os.environ.get('WERKZEUG_RUN_MAIN') == 'true' debug_mode = app.config.get('DEBUG', False) should_log_startup = not debug_mode or is_reloader_process @@ -59,42 +60,32 @@ def create_app(config_class=Config): logger.info("MiroFish Backend starting...") logger.info("=" * 50) - # Enable CORS - CORS(app, resources={r"/api/*": {"origins": "*"}}) + CORS(app, resources={r"/api/*": {"origins": "*"}}, supports_credentials=True) - # Register simulation process cleanup (ensures all simulation processes are terminated on server shutdown) from .services.simulation_runner import SimulationRunner SimulationRunner.register_cleanup() - if should_log_startup: - logger.info("Simulation process cleanup handler registered") - # Middleware d'autenticació JWT — s'executa ABANS del log_request (ordre FIFO) @app.before_request def require_auth(): if app.config.get('TESTING'): - return None # skip auth in test mode - if request.path in _PUBLIC_PATHS or request.method == 'OPTIONS': + return None + if request.method == 'OPTIONS': + return None + if request.path in _PUBLIC_PATHS: + return None + if any(request.path.startswith(p) for p in _PUBLIC_PREFIXES): return None if not request.path.startswith('/api/'): - return None # rutes estàtiques del SPA no requereixen token - auth_header = request.headers.get('Authorization', '') - if not auth_header.startswith('Bearer '): - return jsonify({'success': False, 'error': 'Missing token'}), 401 - token = auth_header[len('Bearer '):] + return None try: - jwt.decode(token, app.config['SECRET_KEY'], algorithms=['HS256']) - except jwt.ExpiredSignatureError: - return jsonify({'success': False, 'error': 'Token expired'}), 401 - except jwt.InvalidTokenError: - return jsonify({'success': False, 'error': 'Invalid token'}), 401 + verify_jwt_in_request() + except Exception: + return jsonify({'success': False, 'error': 'Missing or invalid token'}), 401 - # Request logging middleware @app.before_request def log_request(): logger = get_logger('mirofish.request') logger.debug(f"Request: {request.method} {request.path}") - if request.content_type and 'json' in request.content_type: - logger.debug(f"Request body: {request.get_json(silent=True)}") @app.after_request def log_response(response): @@ -102,20 +93,18 @@ def create_app(config_class=Config): logger.debug(f"Response: {response.status_code}") return response - # Register blueprints (auth first, then the rest) - from .api import graph_bp, simulation_bp, report_bp, auth_bp + from .api import graph_bp, simulation_bp, report_bp, auth_bp, users_bp, admin_bp app.register_blueprint(auth_bp, url_prefix='/api/auth') app.register_blueprint(graph_bp, url_prefix='/api/graph') app.register_blueprint(simulation_bp, url_prefix='/api/simulation') app.register_blueprint(report_bp, url_prefix='/api/report') + app.register_blueprint(users_bp, url_prefix='/api/users') + app.register_blueprint(admin_bp, url_prefix='/api/admin') - # Health check @app.route('/health') def health(): return {'status': 'ok', 'service': 'MiroFish Backend'} - # Servir el SPA de Vue compilat (producció: quan existeix frontend/dist/) - # La catch-all es registra al final perquè les rutes /api/* tinguin prioritat import os as _os from flask import send_from_directory, send_file as _send_file _dist = _os.path.join(_os.path.dirname(__file__), '../../frontend/dist') @@ -135,28 +124,60 @@ def create_app(config_class=Config): def get_storage(): - """Accés al StorageService des de qualsevol context Flask.""" from flask import current_app return current_app.extensions['storage'] def get_current_user(): - """Retorna l'usuari autenticat via JWT Bearer token, o None si no hi ha token vàlid.""" - from flask import request - from flask_jwt_extended import decode_token - from .db import get_session - from .models.db_models import UserModel - auth_header = request.headers.get('Authorization', '') - if not auth_header.startswith('Bearer '): + """Retorna el UserModel autenticat o None (en mode TESTING).""" + from flask import current_app + if current_app.config.get('TESTING'): return None - token = auth_header[len('Bearer '):] try: - decoded = decode_token(token) - user_id = decoded.get('sub') + user_id = get_jwt_identity() except Exception: return None + if not user_id: + return None + from .db import get_session + from .models.db_models import UserModel with get_session() as db: user = db.get(UserModel, user_id) if user: db.expunge(user) return user + + +def require_admin(f): + """Decorator: requereix role=='admin'. Saltat en mode TESTING.""" + @wraps(f) + def decorated(*args, **kwargs): + from flask import current_app + if not current_app.config.get('TESTING'): + user = get_current_user() + if not user or user.role != 'admin': + return jsonify({'success': False, 'error': 'Admin required'}), 403 + return f(*args, **kwargs) + return decorated + + +def require_project_owner(f): + """Decorator: requereix ser propietari del projecte o admin. Saltat en TESTING.""" + @wraps(f) + def decorated(*args, **kwargs): + from flask import current_app + if not current_app.config.get('TESTING'): + user = get_current_user() + if not user: + return jsonify({'success': False, 'error': 'Not authenticated'}), 401 + if user.role != 'admin': + project_id = kwargs.get('project_id') + if project_id: + from .db import get_session + from .models.db_models import ProjectModel + with get_session() as db: + proj = db.get(ProjectModel, project_id) + if proj and proj.user_id and proj.user_id != user.id: + return jsonify({'success': False, 'error': 'Forbidden'}), 403 + return f(*args, **kwargs) + return decorated diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 7ea18388..55a6be52 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -1,16 +1,16 @@ -""" -API routes module -""" - +"""API routes module""" from flask import Blueprint graph_bp = Blueprint('graph', __name__) simulation_bp = Blueprint('simulation', __name__) report_bp = Blueprint('report', __name__) auth_bp = Blueprint('auth', __name__) +users_bp = Blueprint('users', __name__) +admin_bp = Blueprint('admin', __name__) -from . import graph # noqa: E402, F401 -from . import simulation # noqa: E402, F401 -from . import report # noqa: E402, F401 -from . import auth # noqa: E402, F401 - +from . import graph # noqa +from . import simulation # noqa +from . import report # noqa +from . import auth # noqa +from . import users # noqa +from . import admin # noqa diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/app/api/users.py b/backend/app/api/users.py new file mode 100644 index 00000000..e69de29b