Files
turf_saas/saas_api.py
DevOps Engineer 5a23692ad1 feat: Sprint 2-3 — Auth JWT + Multi-tenant (HRT-28)
- auth_db.py: create users, subscriptions, refresh_tokens tables in turf_saas.db
- auth.py: register/login/refresh/logout endpoints, JWT middleware, plan_required decorator, free daily-limit check
- middleware.py: in-memory rate limiter (100 req/min/IP), timestamped access logs
- saas_api.py: Flask app factory wiring JWT, CORS, blueprints, /api/v1/predictions plan-gating
- tests/test_auth.py: 27 pytest tests, 83% coverage (target >=80%)
- API_AUTH.md: full endpoint documentation

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-25 17:35:45 +02:00

248 lines
8.5 KiB
Python

#!/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)