- 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>
248 lines
8.5 KiB
Python
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)
|