- Nouveaux fichiers: api_tokens_db.py, api_v1/routes/user_tokens.py, api_v1/utils_webhook.py - Migration DB idempotente: tables user_api_tokens + user_webhooks - Endpoints POST/DELETE /api/v1/user/api-token (Pro only) - Endpoints POST/DELETE /api/v1/user/webhook (Pro only, HTTPS requis) - HMAC-SHA256 fire-and-forget dispatch webhook - auth.py: validate_api_key() + X-API-Key fallback dans jwt_required_middleware - saas_auth.py: import logging au niveau module, validate_api_key(), X-API-Key fallback - api_v1/__init__.py: enregistrement user_tokens_bp - 24 tests pytest — tous passent Co-Authored-By: Paperclip <noreply@paperclip.ing>
409 lines
13 KiB
Python
409 lines
13 KiB
Python
#!/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
|