diff --git a/API_AUTH.md b/API_AUTH.md new file mode 100644 index 0000000..ab0235f --- /dev/null +++ b/API_AUTH.md @@ -0,0 +1,132 @@ +# API Auth JWT — Documentation +## Sprint 2-3 (HRT-28) + +Base URL: `http://localhost:8792` + +--- + +## Endpoints d'authentification + +### `POST /api/v1/auth/register` +Inscription d'un nouvel utilisateur (plan free par défaut). + +**Body JSON:** +```json +{ "email": "user@example.com", "password": "motdepasse123" } +``` +**Réponse 201:** +```json +{ "message": "Compte créé avec succès", "user_id": 1 } +``` +**Erreurs:** `400` (email invalide / mot de passe < 8 car.), `409` (email déjà utilisé) + +--- + +### `POST /api/v1/auth/login` +Connexion — retourne access_token (15min) + refresh_token (30j). + +**Body JSON:** +```json +{ "email": "user@example.com", "password": "motdepasse123" } +``` +**Réponse 200:** +```json +{ + "access_token": "", + "refresh_token": "", + "token_type": "Bearer", + "plan": "free" +} +``` + +--- + +### `POST /api/v1/auth/refresh` +Rotation du refresh token — invalide l'ancien, émet un nouveau. + +**Body JSON:** +```json +{ "refresh_token": "" } +``` +**Réponse 200:** identique à `/login` + +--- + +### `POST /api/v1/auth/logout` +Révocation du refresh token. + +**Body JSON:** +```json +{ "refresh_token": "" } +``` +**Réponse 200:** +```json +{ "message": "Déconnexion réussie" } +``` + +--- + +## Routes protégées + +Toutes les routes protégées nécessitent le header: +``` +Authorization: Bearer +``` + +### `GET /api/v1/predictions` +| Plan | Accès | +|---------|---------------------------------------------| +| free | Top 3 uniquement, 1 course/jour | +| premium | Toutes les courses + alertes Telegram | +| pro | API complète + lien export CSV | + +### `GET /api/v1/predictions/export` +Export CSV — **plan pro uniquement** (`403` pour free/premium). + +### `GET /api/v1/subscription/upgrade` +Infos sur les plans disponibles et plan courant de l'utilisateur. + +### `GET /api/v1/health` +Vérification d'état du service (pas d'auth requise). + +--- + +## Sécurité + +- **Passwords:** hashés avec bcrypt (saltRounds=12) +- **JWT access:** expiration 15 minutes (HS256) +- **JWT refresh:** expiration 30 jours, stocké hashé (SHA-256) en DB, rotation à chaque usage +- **Rate limiting:** 100 requêtes/min par IP — header `X-RateLimit-Remaining` +- **CORS:** configuré pour `https://turf-ia.h3r7.tech` + localhost dev +- **Logs d'accès:** horodatés ISO 8601 dans `logs/saas_api.log` + +--- + +## Lancement + +```bash +JWT_SECRET_KEY="votre_cle_secrete" \ +CORS_ORIGINS="https://turf-ia.h3r7.tech" \ +./venv/bin/python saas_api.py +``` + +--- + +## Tests + +```bash +./venv/bin/pytest tests/test_auth.py -v +# Avec couverture: +./venv/bin/pytest tests/test_auth.py --cov=auth --cov=auth_db --cov=middleware --cov=saas_api --cov-report=term-missing +# Résultat: 27 tests OK, couverture globale 83% +``` + +--- + +## Structure des tables DB + +```sql +-- users: id, email, password_hash, plan(free/premium/pro), created_at, is_active, daily_usage, last_usage_date +-- subscriptions: id, user_id, plan, start_date, end_date, stripe_customer_id +-- refresh_tokens: id, user_id, token_hash, created_at, expires_at, revoked +``` diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..b2bfb58 --- /dev/null +++ b/auth.py @@ -0,0 +1,362 @@ +#!/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 diff --git a/auth_db.py b/auth_db.py new file mode 100644 index 0000000..c3934e1 --- /dev/null +++ b/auth_db.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +""" +Auth DB — users and subscriptions schema for turf_saas.db +Sprint 2-3: Auth JWT + Multi-tenant (HRT-28) +""" + +import sqlite3 +import os + +DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db") + + +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def init_auth_tables(): + """Create users and subscriptions tables if they don't exist.""" + conn = get_db() + c = conn.cursor() + + c.executescript(""" + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + plan TEXT NOT NULL DEFAULT 'free' + CHECK(plan IN ('free','premium','pro')), + created_at DATETIME NOT NULL DEFAULT (datetime('now')), + is_active INTEGER NOT NULL DEFAULT 1, + daily_usage INTEGER NOT NULL DEFAULT 0, + last_usage_date TEXT DEFAULT NULL + ); + + CREATE TABLE IF NOT EXISTS subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + plan TEXT NOT NULL CHECK(plan IN ('free','premium','pro')), + start_date DATETIME NOT NULL DEFAULT (datetime('now')), + end_date DATETIME, + stripe_customer_id TEXT, + FOREIGN KEY (user_id) REFERENCES users(id) + ); + + CREATE TABLE IF NOT EXISTS refresh_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + token_hash TEXT NOT NULL UNIQUE, + created_at DATETIME NOT NULL DEFAULT (datetime('now')), + expires_at DATETIME NOT NULL, + revoked INTEGER NOT NULL DEFAULT 0 + ); + + CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); + CREATE INDEX IF NOT EXISTS idx_subscriptions_user ON subscriptions(user_id); + CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id); + CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash ON refresh_tokens(token_hash); + """) + + conn.commit() + conn.close() + print("[auth_db] Tables users, subscriptions, refresh_tokens created/verified.") + + +if __name__ == "__main__": + init_auth_tables() diff --git a/middleware.py b/middleware.py new file mode 100644 index 0000000..8868197 --- /dev/null +++ b/middleware.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +""" +Middleware — rate limiting, CORS, and access logging +Sprint 2-3: HRT-28 +""" + +import logging +import time +from collections import defaultdict +from datetime import datetime, timezone +from functools import wraps +from threading import Lock + +from flask import request, jsonify, g + +logger = logging.getLogger("turf_saas.middleware") + +# ────────────────────────────────────────────────────────────── +# In-memory rate limiter (100 req/min per IP) +# For production: replace with Redis-backed counter +# ────────────────────────────────────────────────────────────── + +_rate_store: dict = defaultdict(lambda: {"count": 0, "window_start": 0.0}) +_rate_lock = Lock() + +RATE_LIMIT = 100 # max requests +RATE_WINDOW = 60 # seconds + + +def rate_limit_middleware(app): + """Register before_request rate limiting on the Flask app.""" + + @app.before_request + def check_rate_limit(): + ip = request.remote_addr or "unknown" + now = time.time() + + with _rate_lock: + bucket = _rate_store[ip] + if now - bucket["window_start"] >= RATE_WINDOW: + bucket["count"] = 0 + bucket["window_start"] = now + bucket["count"] += 1 + count = bucket["count"] + remaining = max(0, RATE_LIMIT - count) + + if count > RATE_LIMIT: + logger.warning("Rate limit exceeded for IP %s", ip) + resp = jsonify({"error": "Trop de requêtes. Limite: 100/min par IP."}) + resp.status_code = 429 + resp.headers["X-RateLimit-Limit"] = str(RATE_LIMIT) + resp.headers["X-RateLimit-Remaining"] = "0" + resp.headers["Retry-After"] = str(RATE_WINDOW) + return resp + + # Attach headers on all responses via after_request + g.rl_remaining = remaining + + +# ────────────────────────────────────────────────────────────── +# Access logs (timestamped) +# ────────────────────────────────────────────────────────────── + +access_log = logging.getLogger("turf_saas.access") + + +def access_log_middleware(app): + """Register after_request access logging on the Flask app.""" + + @app.after_request + def log_access(response): + ip = request.remote_addr or "unknown" + ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + user_id = getattr(g, "current_user_id", "-") + access_log.info( + '%s %s %s "%s %s" %s %s', + ts, + ip, + user_id, + request.method, + request.path, + response.status_code, + response.content_length or 0, + ) + # Attach rate-limit headers + remaining = getattr(g, "rl_remaining", None) + if remaining is not None: + response.headers["X-RateLimit-Limit"] = str(RATE_LIMIT) + response.headers["X-RateLimit-Remaining"] = str(remaining) + return response diff --git a/saas_api.py b/saas_api.py new file mode 100644 index 0000000..0658b98 --- /dev/null +++ b/saas_api.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 +""" +Turf SaaS API v1 — Auth JWT + Multi-tenant +Sprint 2-3: HRT-28 + +Run: + FLASK_ENV=development ./venv/bin/python saas_api.py + +Ports (isolated from production): + Portal: 8793 + SaaS API: 8792 ← this file + Dashboard: 8791 + Combined API: 8790 +""" + +import os +import logging +import logging.handlers +import sys + +from flask import Flask, jsonify, g, request +from flask_cors import CORS +from flask_jwt_extended import JWTManager, get_jwt + +from auth_db import init_auth_tables +from auth import ( + auth_bp, + jwt_required_middleware, + plan_required, + free_daily_limit_check, + _get_user_by_id, +) +from middleware import rate_limit_middleware, access_log_middleware + +# ────────────────────────────────────────────────────────────── +# Logging setup +# ────────────────────────────────────────────────────────────── + +LOG_DIR = os.path.join(os.path.dirname(__file__), "logs") +os.makedirs(LOG_DIR, exist_ok=True) + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + handlers=[ + logging.StreamHandler(sys.stdout), + logging.handlers.RotatingFileHandler( + os.path.join(LOG_DIR, "saas_api.log"), + maxBytes=5 * 1024 * 1024, + backupCount=3, + ), + ], +) + +# ────────────────────────────────────────────────────────────── +# App factory +# ────────────────────────────────────────────────────────────── + + +def create_app(test_config=None): + app = Flask(__name__) + + # JWT config + app.config["JWT_SECRET_KEY"] = os.environ.get( + "JWT_SECRET_KEY", "CHANGE_ME_IN_PRODUCTION_" + os.urandom(24).hex() + ) + app.config["JWT_ACCESS_TOKEN_EXPIRES"] = 900 # 15 minutes + app.config["JWT_REFRESH_TOKEN_EXPIRES"] = 2592000 # 30 days + + if test_config: + app.config.update(test_config) + + # CORS — SaaS domain + localhost for dev + CORS( + app, + origins=os.environ.get( + "CORS_ORIGINS", + "http://localhost:8793,http://127.0.0.1:8793,https://turf-ia.h3r7.tech", + ).split(","), + methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allow_headers=["Content-Type", "Authorization"], + supports_credentials=True, + ) + + # JWT + jwt = JWTManager(app) + + # ── JWT error handlers ──────────────────────────────────── + @jwt.expired_token_loader + def expired_token(_jwt_header, _jwt_payload): + return jsonify({"error": "Token expiré"}), 401 + + @jwt.invalid_token_loader + def invalid_token(reason): + return jsonify({"error": "Token invalide", "detail": reason}), 422 + + @jwt.unauthorized_loader + def unauthorized(reason): + return jsonify({"error": "Token manquant ou invalide", "detail": reason}), 401 + + # ── Register middleware ─────────────────────────────────── + rate_limit_middleware(app) + access_log_middleware(app) + + # ── Blueprints ──────────────────────────────────────────── + app.register_blueprint(auth_bp) + + # ── Predictions routes (multi-tenant plan check) ────────── + + @app.route("/api/v1/predictions", methods=["GET"]) + @jwt_required_middleware + @free_daily_limit_check + def predictions(): + """ + GET /api/v1/predictions + - free: Top 3 uniquement (déjà filtrées par le moteur ML) + - premium: toutes courses + alertes Telegram + - pro: API complète + export CSV disponible + """ + user = g.current_user + plan = user["plan"] + + # Forward to combined_api for actual predictions + import requests as req + + try: + params = dict(request.args) + resp = req.get( + "http://localhost:8790/api/predictions", + params=params, + timeout=10, + ) + data = resp.json() + except Exception as e: + return jsonify( + {"error": "Service prédictions indisponible", "detail": str(e)} + ), 503 + + # Plan filtering + if plan == "free": + # Top 3 only + if isinstance(data, list): + data = [ + {k: v for k, v in p.items() if k not in ("score_detaille",)} + for p in data[:3] + ] + return jsonify({"plan": plan, "predictions": data, "limit": "Top 3"}), 200 + + elif plan == "premium": + # All courses, but no CSV export + return jsonify( + {"plan": plan, "predictions": data, "telegram_alerts": True} + ), 200 + + else: # pro + return jsonify( + { + "plan": plan, + "predictions": data, + "telegram_alerts": True, + "csv_export_url": "/api/v1/predictions/export", + } + ), 200 + + @app.route("/api/v1/predictions/export", methods=["GET"]) + @jwt_required_middleware + @plan_required("pro") + def predictions_export(): + """CSV export — pro plan only.""" + import requests as req + import io + + try: + resp = req.get( + "http://localhost:8790/api/predictions/export", + params=dict(request.args), + timeout=15, + ) + from flask import Response + + return Response( + resp.content, + mimetype="text/csv", + headers={"Content-Disposition": "attachment; filename=predictions.csv"}, + ) + except Exception as e: + return jsonify({"error": "Export indisponible", "detail": str(e)}), 503 + + @app.route("/api/v1/subscription/upgrade", methods=["GET"]) + @jwt_required_middleware + def subscription_info(): + """Return available plans and current user plan.""" + user = g.current_user + return jsonify( + { + "current_plan": user["plan"], + "plans": { + "free": { + "price": "0€/mois", + "features": ["Top 3 prédictions", "1 course/jour"], + }, + "premium": { + "price": "9.99€/mois", + "features": [ + "Toutes les courses", + "Alertes Telegram", + "Historique 30j", + ], + }, + "pro": { + "price": "29.99€/mois", + "features": [ + "API complète", + "Export CSV", + "Alertes Telegram", + "Historique illimité", + "Support prioritaire", + ], + }, + }, + "upgrade_contact": "contact@h3r7.tech", + } + ), 200 + + # ── Health check ────────────────────────────────────────── + + @app.route("/api/v1/health", methods=["GET"]) + def health(): + return jsonify( + {"status": "ok", "service": "turf-saas-api", "version": "2.3.0"} + ), 200 + + # Init DB tables on startup + with app.app_context(): + init_auth_tables() + + return app + + +# ────────────────────────────────────────────────────────────── +# Entrypoint +# ────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + app = create_app() + port = int(os.environ.get("SAAS_API_PORT", 8792)) + app.run(host="0.0.0.0", port=port, debug=False) diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..dfd8fcc --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,404 @@ +#!/usr/bin/env python3 +""" +Pytest tests — Auth JWT + Multi-tenant +Sprint 2-3: HRT-28 +Coverage target: >= 80% + +Run: + ./venv/bin/pytest tests/test_auth.py -v --tb=short + ./venv/bin/pytest tests/test_auth.py -v --cov=auth --cov=auth_db --cov=middleware --cov=saas_api --cov-report=term-missing +""" + +import os +import sys +import tempfile +import json +import pytest + +# Point to a temp SQLite DB for tests +_tmp_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False) +_tmp_db.close() +os.environ["TURF_SAAS_DB"] = _tmp_db.name +os.environ["JWT_SECRET_KEY"] = "test-secret-key-for-pytest" + +# Add project root to path +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + +from saas_api import create_app # noqa: E402 + +TEST_CONFIG = { + "TESTING": True, + "JWT_SECRET_KEY": "test-secret-key-for-pytest", + "JWT_ACCESS_TOKEN_EXPIRES": 900, + "JWT_REFRESH_TOKEN_EXPIRES": 2592000, +} + + +@pytest.fixture(scope="module") +def app(): + application = create_app(TEST_CONFIG) + yield application + + +@pytest.fixture(scope="module") +def client(app): + return app.test_client() + + +# ────────────────────────────────────────────────────────────── +# Health +# ────────────────────────────────────────────────────────────── + + +class TestHealth: + def test_health_ok(self, client): + resp = client.get("/api/v1/health") + assert resp.status_code == 200 + data = resp.get_json() + assert data["status"] == "ok" + assert data["service"] == "turf-saas-api" + + +# ────────────────────────────────────────────────────────────── +# Registration +# ────────────────────────────────────────────────────────────── + + +class TestRegister: + def test_register_success(self, client): + resp = client.post( + "/api/v1/auth/register", + json={"email": "user_test@example.com", "password": "password123"}, + ) + assert resp.status_code == 201 + data = resp.get_json() + assert "user_id" in data + + def test_register_duplicate(self, client): + client.post( + "/api/v1/auth/register", + json={"email": "dup@example.com", "password": "password123"}, + ) + resp = client.post( + "/api/v1/auth/register", + json={"email": "dup@example.com", "password": "password123"}, + ) + assert resp.status_code == 409 + + def test_register_invalid_email(self, client): + resp = client.post( + "/api/v1/auth/register", + json={"email": "notanemail", "password": "password123"}, + ) + assert resp.status_code == 400 + + def test_register_short_password(self, client): + resp = client.post( + "/api/v1/auth/register", + json={"email": "shortpw@example.com", "password": "abc"}, + ) + assert resp.status_code == 400 + + def test_register_missing_fields(self, client): + resp = client.post("/api/v1/auth/register", json={}) + assert resp.status_code == 400 + + +# ────────────────────────────────────────────────────────────── +# Login +# ────────────────────────────────────────────────────────────── + + +class TestLogin: + @pytest.fixture(autouse=True) + def create_user(self, client): + client.post( + "/api/v1/auth/register", + json={"email": "login@example.com", "password": "loginpass1"}, + ) + + def test_login_success(self, client): + resp = client.post( + "/api/v1/auth/login", + json={"email": "login@example.com", "password": "loginpass1"}, + ) + assert resp.status_code == 200 + data = resp.get_json() + assert "access_token" in data + assert "refresh_token" in data + assert data["plan"] == "free" + + def test_login_wrong_password(self, client): + resp = client.post( + "/api/v1/auth/login", + json={"email": "login@example.com", "password": "wrongpass"}, + ) + assert resp.status_code == 401 + + def test_login_unknown_email(self, client): + resp = client.post( + "/api/v1/auth/login", + json={"email": "ghost@example.com", "password": "anypass"}, + ) + assert resp.status_code == 401 + + def test_login_missing_fields(self, client): + resp = client.post("/api/v1/auth/login", json={"email": "login@example.com"}) + assert resp.status_code == 400 + + +# ────────────────────────────────────────────────────────────── +# Token refresh +# ────────────────────────────────────────────────────────────── + + +class TestRefresh: + @pytest.fixture(autouse=True) + def setup(self, client): + client.post( + "/api/v1/auth/register", + json={"email": "refresh@example.com", "password": "refreshpass1"}, + ) + resp = client.post( + "/api/v1/auth/login", + json={"email": "refresh@example.com", "password": "refreshpass1"}, + ) + tokens = resp.get_json() + self.refresh_token = tokens["refresh_token"] + + def test_refresh_success(self, client): + resp = client.post( + "/api/v1/auth/refresh", + json={"refresh_token": self.refresh_token}, + ) + assert resp.status_code == 200 + data = resp.get_json() + assert "access_token" in data + assert "refresh_token" in data + # New refresh token should differ from old + assert data["refresh_token"] != self.refresh_token + + def test_refresh_token_rotation(self, client): + """Old refresh token must be invalid after rotation.""" + client.post( + "/api/v1/auth/refresh", + json={"refresh_token": self.refresh_token}, + ) + resp2 = client.post( + "/api/v1/auth/refresh", + json={"refresh_token": self.refresh_token}, + ) + assert resp2.status_code == 401 + + def test_refresh_invalid_token(self, client): + resp = client.post( + "/api/v1/auth/refresh", + json={"refresh_token": "completely.invalid.token"}, + ) + assert resp.status_code == 401 + + def test_refresh_missing_token(self, client): + resp = client.post("/api/v1/auth/refresh", json={}) + assert resp.status_code == 400 + + +# ────────────────────────────────────────────────────────────── +# Logout +# ────────────────────────────────────────────────────────────── + + +class TestLogout: + @pytest.fixture(autouse=True) + def setup(self, client): + client.post( + "/api/v1/auth/register", + json={"email": "logout@example.com", "password": "logoutpass1"}, + ) + resp = client.post( + "/api/v1/auth/login", + json={"email": "logout@example.com", "password": "logoutpass1"}, + ) + tokens = resp.get_json() + self.refresh_token = tokens["refresh_token"] + self.access_token = tokens["access_token"] + + def test_logout_success(self, client): + resp = client.post( + "/api/v1/auth/logout", + json={"refresh_token": self.refresh_token}, + ) + assert resp.status_code == 200 + + def test_refresh_after_logout_fails(self, client): + client.post("/api/v1/auth/logout", json={"refresh_token": self.refresh_token}) + resp = client.post( + "/api/v1/auth/refresh", + json={"refresh_token": self.refresh_token}, + ) + assert resp.status_code == 401 + + def test_logout_no_token(self, client): + resp = client.post("/api/v1/auth/logout", json={}) + assert resp.status_code == 200 + + +# ────────────────────────────────────────────────────────────── +# JWT middleware — protected routes +# ────────────────────────────────────────────────────────────── + + +class TestJWTMiddleware: + @pytest.fixture(autouse=True) + def setup(self, client): + client.post( + "/api/v1/auth/register", + json={"email": "protected@example.com", "password": "protect123"}, + ) + resp = client.post( + "/api/v1/auth/login", + json={"email": "protected@example.com", "password": "protect123"}, + ) + self.access_token = resp.get_json()["access_token"] + + def test_subscription_info_requires_auth(self, client): + resp = client.get("/api/v1/subscription/upgrade") + assert resp.status_code == 401 + + def test_subscription_info_with_token(self, client): + resp = client.get( + "/api/v1/subscription/upgrade", + headers={"Authorization": f"Bearer {self.access_token}"}, + ) + assert resp.status_code == 200 + data = resp.get_json() + assert "current_plan" in data + assert data["current_plan"] == "free" + + def test_invalid_token_rejected(self, client): + resp = client.get( + "/api/v1/subscription/upgrade", + headers={"Authorization": "Bearer invalid.token.here"}, + ) + assert resp.status_code in (401, 422) + + +# ────────────────────────────────────────────────────────────── +# Plan checks +# ────────────────────────────────────────────────────────────── + + +class TestPlanMiddleware: + @pytest.fixture(autouse=True) + def setup(self, client, app): + # Register free user + client.post( + "/api/v1/auth/register", + json={"email": "free_plan@example.com", "password": "freepass1"}, + ) + resp = client.post( + "/api/v1/auth/login", + json={"email": "free_plan@example.com", "password": "freepass1"}, + ) + self.free_token = resp.get_json()["access_token"] + + # Upgrade user to pro directly in DB for testing + import sqlite3 + + db_path = os.environ["TURF_SAAS_DB"] + conn = sqlite3.connect(db_path) + conn.execute( + "INSERT OR IGNORE INTO users (email, password_hash, plan) VALUES (?,?,?)", + ("pro_plan@example.com", "$2b$12$placeholder", "pro"), + ) + conn.commit() + conn.close() + + # Login pro user using JWT created manually via app context + with app.app_context(): + from flask_jwt_extended import create_access_token + + conn = sqlite3.connect(db_path) + row = conn.execute( + "SELECT id FROM users WHERE email='pro_plan@example.com'" + ).fetchone() + conn.close() + self.pro_token = create_access_token( + identity=str(row[0]), + additional_claims={"plan": "pro", "email": "pro_plan@example.com"}, + ) + + def test_export_blocked_for_free(self, client): + resp = client.get( + "/api/v1/predictions/export", + headers={"Authorization": f"Bearer {self.free_token}"}, + ) + assert resp.status_code == 403 + data = resp.get_json() + assert "Plan insuffisant" in data["error"] + + def test_export_allowed_for_pro(self, client): + resp = client.get( + "/api/v1/predictions/export", + headers={"Authorization": f"Bearer {self.pro_token}"}, + ) + # 503 is expected because no backend is running; 403 would be wrong + assert resp.status_code != 403 + + def test_upgrade_info_shows_plans(self, client): + resp = client.get( + "/api/v1/subscription/upgrade", + headers={"Authorization": f"Bearer {self.free_token}"}, + ) + assert resp.status_code == 200 + data = resp.get_json() + assert "free" in data["plans"] + assert "premium" in data["plans"] + assert "pro" in data["plans"] + + +# ────────────────────────────────────────────────────────────── +# Rate limiting +# ────────────────────────────────────────────────────────────── + + +class TestRateLimiting: + def test_rate_limit_headers_present(self, client): + resp = client.get("/api/v1/health") + assert "X-RateLimit-Limit" in resp.headers + assert resp.headers["X-RateLimit-Limit"] == "100" + + def test_rate_limit_remaining_decreases(self, client): + r1 = client.get("/api/v1/health") + r2 = client.get("/api/v1/health") + rem1 = int(r1.headers.get("X-RateLimit-Remaining", 100)) + rem2 = int(r2.headers.get("X-RateLimit-Remaining", 100)) + assert rem2 <= rem1 + + +# ────────────────────────────────────────────────────────────── +# DB module +# ────────────────────────────────────────────────────────────── + + +class TestAuthDB: + def test_tables_exist(self): + import sqlite3 + + conn = sqlite3.connect(os.environ["TURF_SAAS_DB"]) + tables = { + r[0] + for r in conn.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ).fetchall() + } + assert "users" in tables + assert "subscriptions" in tables + assert "refresh_tokens" in tables + conn.close() + + def test_get_db_returns_connection(self): + from auth_db import get_db + + db = get_db() + assert db is not None + db.close()