feat(auth): JWTManager, get_current_user, require_admin, require_project_owner decorators
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
949630100c
commit
cca6564dad
|
|
@ -1,24 +1,30 @@
|
||||||
"""
|
"""MiroFish Backend - Flask application factory"""
|
||||||
MiroFish Backend - Flask application factory
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import warnings
|
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.*")
|
warnings.filterwarnings("ignore", message=".*resource_tracker.*")
|
||||||
|
|
||||||
import jwt
|
|
||||||
from flask import Flask, request, jsonify
|
from flask import Flask, request, jsonify
|
||||||
from flask_cors import CORS
|
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 .config import Config
|
||||||
from .utils.logger import setup_logger, get_logger
|
from .utils.logger import setup_logger, get_logger
|
||||||
|
|
||||||
# Rutes públiques que no requereixen token JWT
|
# Rutes públiques (sense JWT requerit)
|
||||||
_PUBLIC_PATHS = {'/health', '/api/auth/login'}
|
_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):
|
def create_app(config_class=Config):
|
||||||
|
|
@ -38,18 +44,13 @@ def create_app(config_class=Config):
|
||||||
from .storage import create_storage_service
|
from .storage import create_storage_service
|
||||||
app.extensions['storage'] = create_storage_service()
|
app.extensions['storage'] = create_storage_service()
|
||||||
|
|
||||||
# Inicialitzar JWT
|
# flask-jwt-extended
|
||||||
JWTManager(app)
|
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'):
|
if hasattr(app, 'json') and hasattr(app.json, 'ensure_ascii'):
|
||||||
app.json.ensure_ascii = False
|
app.json.ensure_ascii = False
|
||||||
|
|
||||||
# Set up logging
|
|
||||||
logger = setup_logger('mirofish')
|
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'
|
is_reloader_process = os.environ.get('WERKZEUG_RUN_MAIN') == 'true'
|
||||||
debug_mode = app.config.get('DEBUG', False)
|
debug_mode = app.config.get('DEBUG', False)
|
||||||
should_log_startup = not debug_mode or is_reloader_process
|
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("MiroFish Backend starting...")
|
||||||
logger.info("=" * 50)
|
logger.info("=" * 50)
|
||||||
|
|
||||||
# Enable CORS
|
CORS(app, resources={r"/api/*": {"origins": "*"}}, supports_credentials=True)
|
||||||
CORS(app, resources={r"/api/*": {"origins": "*"}})
|
|
||||||
|
|
||||||
# Register simulation process cleanup (ensures all simulation processes are terminated on server shutdown)
|
|
||||||
from .services.simulation_runner import SimulationRunner
|
from .services.simulation_runner import SimulationRunner
|
||||||
SimulationRunner.register_cleanup()
|
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
|
@app.before_request
|
||||||
def require_auth():
|
def require_auth():
|
||||||
if app.config.get('TESTING'):
|
if app.config.get('TESTING'):
|
||||||
return None # skip auth in test mode
|
return None
|
||||||
if request.path in _PUBLIC_PATHS or request.method == 'OPTIONS':
|
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
|
return None
|
||||||
if not request.path.startswith('/api/'):
|
if not request.path.startswith('/api/'):
|
||||||
return None # rutes estàtiques del SPA no requereixen token
|
return None
|
||||||
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 '):]
|
|
||||||
try:
|
try:
|
||||||
jwt.decode(token, app.config['SECRET_KEY'], algorithms=['HS256'])
|
verify_jwt_in_request()
|
||||||
except jwt.ExpiredSignatureError:
|
except Exception:
|
||||||
return jsonify({'success': False, 'error': 'Token expired'}), 401
|
return jsonify({'success': False, 'error': 'Missing or invalid token'}), 401
|
||||||
except jwt.InvalidTokenError:
|
|
||||||
return jsonify({'success': False, 'error': 'Invalid token'}), 401
|
|
||||||
|
|
||||||
# Request logging middleware
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
def log_request():
|
def log_request():
|
||||||
logger = get_logger('mirofish.request')
|
logger = get_logger('mirofish.request')
|
||||||
logger.debug(f"Request: {request.method} {request.path}")
|
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
|
@app.after_request
|
||||||
def log_response(response):
|
def log_response(response):
|
||||||
|
|
@ -102,20 +93,18 @@ def create_app(config_class=Config):
|
||||||
logger.debug(f"Response: {response.status_code}")
|
logger.debug(f"Response: {response.status_code}")
|
||||||
return response
|
return response
|
||||||
|
|
||||||
# Register blueprints (auth first, then the rest)
|
from .api import graph_bp, simulation_bp, report_bp, auth_bp, users_bp, admin_bp
|
||||||
from .api import graph_bp, simulation_bp, report_bp, auth_bp
|
|
||||||
app.register_blueprint(auth_bp, url_prefix='/api/auth')
|
app.register_blueprint(auth_bp, url_prefix='/api/auth')
|
||||||
app.register_blueprint(graph_bp, url_prefix='/api/graph')
|
app.register_blueprint(graph_bp, url_prefix='/api/graph')
|
||||||
app.register_blueprint(simulation_bp, url_prefix='/api/simulation')
|
app.register_blueprint(simulation_bp, url_prefix='/api/simulation')
|
||||||
app.register_blueprint(report_bp, url_prefix='/api/report')
|
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')
|
@app.route('/health')
|
||||||
def health():
|
def health():
|
||||||
return {'status': 'ok', 'service': 'MiroFish Backend'}
|
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
|
import os as _os
|
||||||
from flask import send_from_directory, send_file as _send_file
|
from flask import send_from_directory, send_file as _send_file
|
||||||
_dist = _os.path.join(_os.path.dirname(__file__), '../../frontend/dist')
|
_dist = _os.path.join(_os.path.dirname(__file__), '../../frontend/dist')
|
||||||
|
|
@ -135,28 +124,60 @@ def create_app(config_class=Config):
|
||||||
|
|
||||||
|
|
||||||
def get_storage():
|
def get_storage():
|
||||||
"""Accés al StorageService des de qualsevol context Flask."""
|
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
return current_app.extensions['storage']
|
return current_app.extensions['storage']
|
||||||
|
|
||||||
|
|
||||||
def get_current_user():
|
def get_current_user():
|
||||||
"""Retorna l'usuari autenticat via JWT Bearer token, o None si no hi ha token vàlid."""
|
"""Retorna el UserModel autenticat o None (en mode TESTING)."""
|
||||||
from flask import request
|
from flask import current_app
|
||||||
from flask_jwt_extended import decode_token
|
if current_app.config.get('TESTING'):
|
||||||
from .db import get_session
|
|
||||||
from .models.db_models import UserModel
|
|
||||||
auth_header = request.headers.get('Authorization', '')
|
|
||||||
if not auth_header.startswith('Bearer '):
|
|
||||||
return None
|
return None
|
||||||
token = auth_header[len('Bearer '):]
|
|
||||||
try:
|
try:
|
||||||
decoded = decode_token(token)
|
user_id = get_jwt_identity()
|
||||||
user_id = decoded.get('sub')
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
if not user_id:
|
||||||
|
return None
|
||||||
|
from .db import get_session
|
||||||
|
from .models.db_models import UserModel
|
||||||
with get_session() as db:
|
with get_session() as db:
|
||||||
user = db.get(UserModel, user_id)
|
user = db.get(UserModel, user_id)
|
||||||
if user:
|
if user:
|
||||||
db.expunge(user)
|
db.expunge(user)
|
||||||
return 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
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,16 @@
|
||||||
"""
|
"""API routes module"""
|
||||||
API routes module
|
|
||||||
"""
|
|
||||||
|
|
||||||
from flask import Blueprint
|
from flask import Blueprint
|
||||||
|
|
||||||
graph_bp = Blueprint('graph', __name__)
|
graph_bp = Blueprint('graph', __name__)
|
||||||
simulation_bp = Blueprint('simulation', __name__)
|
simulation_bp = Blueprint('simulation', __name__)
|
||||||
report_bp = Blueprint('report', __name__)
|
report_bp = Blueprint('report', __name__)
|
||||||
auth_bp = Blueprint('auth', __name__)
|
auth_bp = Blueprint('auth', __name__)
|
||||||
|
users_bp = Blueprint('users', __name__)
|
||||||
|
admin_bp = Blueprint('admin', __name__)
|
||||||
|
|
||||||
from . import graph # noqa: E402, F401
|
from . import graph # noqa
|
||||||
from . import simulation # noqa: E402, F401
|
from . import simulation # noqa
|
||||||
from . import report # noqa: E402, F401
|
from . import report # noqa
|
||||||
from . import auth # noqa: E402, F401
|
from . import auth # noqa
|
||||||
|
from . import users # noqa
|
||||||
|
from . import admin # noqa
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue