#!/usr/bin/env python3 """ Auth Blueprint — JWT authentication + multi-tenant plan enforcement Sprint 2-3: HRT-28 Endpoints: POST /api/v1/auth/register — email/password registration POST /api/v1/auth/login — returns access_token (15min) + refresh_token (30d) POST /api/v1/auth/refresh — rotate refresh token, issue new access_token POST /api/v1/auth/logout — revoke refresh token Middleware exposed: jwt_required_middleware() — decorator: valid access JWT required plan_required(plans) — decorator: user plan must be in given list """ import os import hashlib import secrets import logging from datetime import datetime, timedelta, timezone from functools import wraps import bcrypt from flask import Blueprint, request, jsonify, g, current_app from flask_jwt_extended import ( JWTManager, create_access_token, create_refresh_token, decode_token, get_jwt_identity, verify_jwt_in_request, ) from flask_jwt_extended.exceptions import JWTExtendedException from jwt.exceptions import PyJWTError from auth_db import get_db logger = logging.getLogger("turf_saas.auth") auth_bp = Blueprint("auth", __name__, url_prefix="/api/v1/auth") # ────────────────────────────────────────────────────────────── # Helpers # ────────────────────────────────────────────────────────────── def _hash_token(raw_token: str) -> str: """SHA-256 hash of a token string for secure DB storage.""" return hashlib.sha256(raw_token.encode()).hexdigest() def _get_user_by_email(email: str): db = get_db() user = db.execute( "SELECT * FROM users WHERE email = ? AND is_active = 1", (email.lower(),) ).fetchone() db.close() return user def _get_user_by_id(user_id: int): db = get_db() user = db.execute( "SELECT * FROM users WHERE id = ? AND is_active = 1", (user_id,) ).fetchone() db.close() return user def _store_refresh_token(user_id: int, raw_token: str, expires_at: datetime): token_hash = _hash_token(raw_token) db = get_db() db.execute( "INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES (?,?,?)", (user_id, token_hash, expires_at.isoformat()), ) db.commit() db.close() def _revoke_refresh_token(raw_token: str): token_hash = _hash_token(raw_token) db = get_db() db.execute( "UPDATE refresh_tokens SET revoked = 1 WHERE token_hash = ?", (token_hash,) ) db.commit() db.close() def _is_refresh_token_valid(raw_token: str, user_id: int) -> bool: token_hash = _hash_token(raw_token) db = get_db() row = db.execute( """SELECT id FROM refresh_tokens WHERE token_hash = ? AND user_id = ? AND revoked = 0 AND expires_at > datetime('now')""", (token_hash, user_id), ).fetchone() db.close() return row is not None # ────────────────────────────────────────────────────────────── # Auth endpoints # ────────────────────────────────────────────────────────────── @auth_bp.route("/register", methods=["POST"]) def register(): """POST /api/v1/auth/register — create a new user account (plan=free).""" data = request.get_json(silent=True) or {} email = (data.get("email") or "").strip().lower() password = data.get("password") or "" if not email or "@" not in email: return jsonify({"error": "Email invalide"}), 400 if len(password) < 8: return jsonify({"error": "Mot de passe trop court (min 8 caractères)"}), 400 # Check uniqueness existing = _get_user_by_email(email) if existing: return jsonify({"error": "Email déjà enregistré"}), 409 password_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() db = get_db() try: cursor = db.execute( "INSERT INTO users (email, password_hash, plan) VALUES (?,?,?)", (email, password_hash, "free"), ) user_id = cursor.lastrowid # Create initial subscription record db.execute( "INSERT INTO subscriptions (user_id, plan) VALUES (?,?)", (user_id, "free"), ) db.commit() except Exception as e: db.rollback() logger.error("register error: %s", e) return jsonify({"error": "Erreur interne"}), 500 finally: db.close() logger.info("New user registered: %s (id=%s)", email, user_id) return jsonify({"message": "Compte créé avec succès", "user_id": user_id}), 201 @auth_bp.route("/login", methods=["POST"]) def login(): """POST /api/v1/auth/login — returns JWT access_token + refresh_token.""" data = request.get_json(silent=True) or {} email = (data.get("email") or "").strip().lower() password = data.get("password") or "" if not email or not password: return jsonify({"error": "Email et mot de passe requis"}), 400 user = _get_user_by_email(email) if not user: return jsonify({"error": "Identifiants invalides"}), 401 if not bcrypt.checkpw(password.encode(), user["password_hash"].encode()): logger.warning("Failed login attempt for %s", email) return jsonify({"error": "Identifiants invalides"}), 401 # Create tokens identity = str(user["id"]) additional_claims = {"plan": user["plan"], "email": user["email"]} access_token = create_access_token( identity=identity, additional_claims=additional_claims, ) raw_refresh = create_refresh_token(identity=identity) refresh_expires = datetime.now(timezone.utc) + timedelta(days=30) _store_refresh_token(user["id"], raw_refresh, refresh_expires) logger.info("User %s logged in (plan=%s)", email, user["plan"]) return jsonify( { "access_token": access_token, "refresh_token": raw_refresh, "token_type": "Bearer", "plan": user["plan"], } ), 200 @auth_bp.route("/refresh", methods=["POST"]) def refresh(): """POST /api/v1/auth/refresh — rotate refresh token, issue new access_token.""" data = request.get_json(silent=True) or {} raw_refresh = (data.get("refresh_token") or "").strip() if not raw_refresh: return jsonify({"error": "refresh_token manquant"}), 400 # Decode without verifying in DB first (to get user_id) try: decoded = decode_token(raw_refresh) except Exception: return jsonify({"error": "Refresh token invalide ou expiré"}), 401 user_id = int(decoded.get("sub", 0)) if not _is_refresh_token_valid(raw_refresh, user_id): return jsonify({"error": "Refresh token invalide, révoqué ou expiré"}), 401 user = _get_user_by_id(user_id) if not user: return jsonify({"error": "Utilisateur introuvable"}), 401 # Revoke old refresh token (rotation) _revoke_refresh_token(raw_refresh) # Issue new tokens identity = str(user["id"]) additional_claims = {"plan": user["plan"], "email": user["email"]} new_access = create_access_token( identity=identity, additional_claims=additional_claims ) new_refresh = create_refresh_token(identity=identity) refresh_expires = datetime.now(timezone.utc) + timedelta(days=30) _store_refresh_token(user["id"], new_refresh, refresh_expires) logger.info("Token refreshed for user_id=%s", user_id) return jsonify( { "access_token": new_access, "refresh_token": new_refresh, "token_type": "Bearer", "plan": user["plan"], } ), 200 @auth_bp.route("/logout", methods=["POST"]) def logout(): """POST /api/v1/auth/logout — revoke refresh token.""" data = request.get_json(silent=True) or {} raw_refresh = (data.get("refresh_token") or "").strip() if raw_refresh: _revoke_refresh_token(raw_refresh) return jsonify({"message": "Déconnexion réussie"}), 200 # ────────────────────────────────────────────────────────────── # JWT-protected middleware # ────────────────────────────────────────────────────────────── def jwt_required_middleware(fn): """Decorator: require a valid Bearer JWT access token.""" @wraps(fn) def wrapper(*args, **kwargs): try: verify_jwt_in_request() user_id = int(get_jwt_identity()) user = _get_user_by_id(user_id) if not user: return jsonify({"error": "Utilisateur introuvable"}), 401 g.current_user = dict(user) g.current_user_id = user_id except (JWTExtendedException, PyJWTError) as e: logger.debug("JWT auth failed: %s", e) return jsonify({"error": "Token invalide ou expiré", "detail": str(e)}), 401 return fn(*args, **kwargs) return wrapper def plan_required(*allowed_plans): """ Decorator factory: user's plan must be in allowed_plans. Must be applied AFTER @jwt_required_middleware. Example: @app.route("/api/v1/predictions") @jwt_required_middleware @plan_required("premium", "pro") def premium_predictions(): ... """ def decorator(fn): @wraps(fn) def wrapper(*args, **kwargs): user = getattr(g, "current_user", None) if not user: return jsonify({"error": "Non authentifié"}), 401 if user["plan"] not in allowed_plans: return jsonify( { "error": "Plan insuffisant", "required": list(allowed_plans), "current_plan": user["plan"], "upgrade_url": "/api/v1/subscription/upgrade", } ), 403 return fn(*args, **kwargs) return wrapper return decorator def free_daily_limit_check(fn): """ Decorator: enforce free plan daily limit (1 course/jour). Must be applied AFTER @jwt_required_middleware. """ @wraps(fn) def wrapper(*args, **kwargs): user = getattr(g, "current_user", None) if not user or user["plan"] != "free": return fn(*args, **kwargs) today = datetime.now(timezone.utc).date().isoformat() db = get_db() row = db.execute( "SELECT daily_usage, last_usage_date FROM users WHERE id = ?", (user["id"],), ).fetchone() db.close() if row and row["last_usage_date"] == today and row["daily_usage"] >= 1: return jsonify( { "error": "Limite quotidienne atteinte (plan free: 1 course/jour)", "upgrade_url": "/api/v1/subscription/upgrade", } ), 429 # Increment usage db = get_db() if row and row["last_usage_date"] == today: db.execute( "UPDATE users SET daily_usage = daily_usage + 1 WHERE id = ?", (user["id"],), ) else: db.execute( "UPDATE users SET daily_usage = 1, last_usage_date = ? WHERE id = ?", (today, user["id"]), ) db.commit() db.close() return fn(*args, **kwargs) return wrapper