Files
turf_saas/auth.py
DevOps Engineer f300e44c74 feat(HRT-80): API Token personnel + Webhook alertes (Pro)
- 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>
2026-04-29 17:25:30 +02:00

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