#!/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 validate_api_key(raw_key: str): """ Validate a personal API token (X-API-Key header). Returns user dict or None. Updates last_used_at on success. HRT-80: Personal API token support. """ if not raw_key: return None key_hash = hashlib.sha256(raw_key.encode()).hexdigest() db = get_db() try: row = db.execute( "SELECT t.user_id, u.* FROM user_api_tokens t " "JOIN users u ON CAST(t.user_id AS INTEGER) = u.id " "WHERE t.token_hash = ? AND t.revoked = 0 AND u.is_active = 1", (key_hash,), ).fetchone() if row: db.execute( "UPDATE user_api_tokens SET last_used_at = datetime('now') " "WHERE token_hash = ?", (key_hash,), ) db.commit() return dict(row) if row else None except Exception as e: logger.warning("validate_api_key error: %s", e) return None finally: db.close() def jwt_required_middleware(fn): """ Decorator: require a valid Bearer JWT access token OR X-API-Key personal token. HRT-80: Added X-API-Key fallback for personal API tokens (Pro plan only). """ @wraps(fn) def wrapper(*args, **kwargs): # 1. Try Bearer JWT (existing flow — unchanged) 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 return fn(*args, **kwargs) except (JWTExtendedException, PyJWTError) as e: logger.debug("JWT auth failed: %s", e) # 2. Fallback: X-API-Key personal token (HRT-80) api_key = request.headers.get("X-API-Key", "").strip() if api_key: user = validate_api_key(api_key) if user: g.current_user = user g.current_user_id = user.get("id") return fn(*args, **kwargs) return jsonify({"error": "Token invalide ou expiré"}), 401 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