Compare commits
3 Commits
fac498efec
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ab42343aa | ||
|
|
cd4cbcfb48 | ||
|
|
c072f92794 |
32
docker-compose.broker.yml
Normal file
32
docker-compose.broker.yml
Normal file
@@ -0,0 +1,32 @@
|
||||
# Token Broker Infrastructure
|
||||
# PostgreSQL dedicated instance on port 5434
|
||||
networks:
|
||||
turf-net:
|
||||
driver: bridge
|
||||
|
||||
services:
|
||||
token-broker-db:
|
||||
image: postgres:16-alpine
|
||||
container_name: token-broker-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: token_broker
|
||||
POSTGRES_USER: token_broker
|
||||
POSTGRES_PASSWORD: ${TOKEN_BROKER_DB_PASSWORD:-CHANGE_ME_PASSWORD}
|
||||
volumes:
|
||||
- token-broker-pgdata:/var/lib/postgresql/data
|
||||
- ./infra/postgres/token_broker_init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U token_broker -d token_broker"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
networks:
|
||||
- turf-net
|
||||
ports:
|
||||
- "127.0.0.1:5434:5432"
|
||||
|
||||
volumes:
|
||||
token-broker-pgdata:
|
||||
driver: local
|
||||
94
infra/postgres/token_broker_init.sql
Normal file
94
infra/postgres/token_broker_init.sql
Normal file
@@ -0,0 +1,94 @@
|
||||
-- Token Broker PostgreSQL init script
|
||||
-- 6 tables: api_tokens, refresh_tokens, token_audit_log, clients, providers, token_usage
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL DEFAULT 'default',
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
token_prefix TEXT NOT NULL,
|
||||
scopes TEXT[] DEFAULT '{}',
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ,
|
||||
last_used_at TIMESTAMPTZ,
|
||||
metadata JSONB DEFAULT '{}'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id INTEGER NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
token_prefix TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
replaced_by UUID
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS token_audit_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id INTEGER,
|
||||
action TEXT NOT NULL,
|
||||
token_prefix TEXT,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
details JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS clients (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
client_id TEXT NOT NULL UNIQUE,
|
||||
client_secret TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
redirect_uris TEXT[] DEFAULT '{}',
|
||||
scopes TEXT[] DEFAULT '{}',
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS providers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
provider_type TEXT NOT NULL DEFAULT 'oauth2',
|
||||
issuer_url TEXT,
|
||||
client_id TEXT,
|
||||
client_secret TEXT,
|
||||
scopes TEXT[] DEFAULT '{}',
|
||||
config JSONB DEFAULT '{}',
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS token_usage (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
token_id UUID,
|
||||
action TEXT NOT NULL DEFAULT 'verify',
|
||||
endpoint TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'success',
|
||||
response_time_ms INTEGER,
|
||||
ip_address TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON api_tokens(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_token_hash ON api_tokens(token_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_token_audit_log_user_id ON token_audit_log(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_token_audit_log_created_at ON token_audit_log(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_clients_client_id ON clients(client_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_providers_name ON providers(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_token_usage_user_id ON token_usage(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_token_usage_created_at ON token_usage(created_at);
|
||||
|
||||
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO token_broker;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO token_broker;
|
||||
90
infra/scripts/deploy_token_broker.sh
Executable file
90
infra/scripts/deploy_token_broker.sh
Executable file
@@ -0,0 +1,90 @@
|
||||
#!/bin/bash
|
||||
# ============================================================
|
||||
# Deploy Token Broker — systemd service + Docker PG
|
||||
# ============================================================
|
||||
set -euo pipefail
|
||||
|
||||
APP_DIR="/home/h3r7/turf_saas"
|
||||
SERVICE_NAME="token-broker"
|
||||
PID_FILE="/tmp/token_broker.pid"
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
|
||||
echo "[$(date -Iseconds)] === Deploying Token Broker ==="
|
||||
|
||||
# Step 1: Backup current code
|
||||
echo "[$(date -Iseconds)] Backing up current code..."
|
||||
mkdir -p /home/h3r7/backups/token-broker
|
||||
cp "${APP_DIR}/services/token-broker/token_broker_api.py" \
|
||||
"/home/h3r7/backups/token-broker/token_broker_api_${TIMESTAMP}.py"
|
||||
|
||||
# Step 2: Ensure Docker PG is running
|
||||
echo "[$(date -Iseconds)] Ensuring PostgreSQL container..."
|
||||
if ! docker inspect token-broker-db >/dev/null 2>&1; then
|
||||
echo "Creating PG container..."
|
||||
docker run -d \
|
||||
--name token-broker-db \
|
||||
--restart unless-stopped \
|
||||
-e POSTGRES_DB=token_broker \
|
||||
-e POSTGRES_USER=token_broker \
|
||||
-e POSTGRES_PASSWORD="${TOKEN_BROKER_DB_PASSWORD}" \
|
||||
-v token-broker-pgdata:/var/lib/postgresql/data \
|
||||
-v "${APP_DIR}/infra/postgres/token_broker_init.sql:/docker-entrypoint-initdb.d/init.sql:ro" \
|
||||
-p 127.0.0.1:5434:5432 \
|
||||
postgres:16-alpine
|
||||
elif ! docker ps --filter name=token-broker-db --format '{{.Status}}' | grep -q Up; then
|
||||
echo "Starting existing PG container..."
|
||||
docker start token-broker-db
|
||||
else
|
||||
echo "PG container already running."
|
||||
fi
|
||||
|
||||
# Wait for PG readiness
|
||||
echo "[$(date -Iseconds)] Waiting for PG to be ready..."
|
||||
for i in $(seq 1 20); do
|
||||
if docker exec token-broker-db pg_isready -U token_broker -d token_broker >/dev/null 2>&1; then
|
||||
echo "PG ready."
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# Step 3: Ensure psycopg2-binary is installed
|
||||
echo "[$(date -Iseconds)] Checking Python deps..."
|
||||
source "${APP_DIR}/venv/bin/activate"
|
||||
pip install -q psycopg2-binary PyJWT flask-cors python-dotenv gunicorn 2>/dev/null || true
|
||||
|
||||
# Step 4: Stop current service
|
||||
echo "[$(date -Iseconds)] Stopping current service..."
|
||||
if systemctl is-active --quiet ${SERVICE_NAME} 2>/dev/null; then
|
||||
systemctl stop ${SERVICE_NAME}
|
||||
elif [ -f "$PID_FILE" ] && kill -0 $(cat "$PID_FILE") 2>/dev/null; then
|
||||
kill $(cat "$PID_FILE") 2>/dev/null || true
|
||||
fi
|
||||
sleep 2
|
||||
|
||||
# Step 5: Copy systemd unit and start
|
||||
echo "[$(date -Iseconds)] Starting via systemd..."
|
||||
cp "${APP_DIR}/services/token-broker/token-broker.service" /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
systemctl enable ${SERVICE_NAME}
|
||||
systemctl start ${SERVICE_NAME}
|
||||
|
||||
# Wait for startup
|
||||
sleep 3
|
||||
|
||||
# Step 6: Health check
|
||||
echo "[$(date -Iseconds)] Running health check..."
|
||||
HEALTH=$(curl -s http://127.0.0.1:8783/health 2>/dev/null || echo '{"status":"failed"}')
|
||||
STATUS=$(echo "$HEALTH" | python3 -c "import sys,json; print(json.load(sys.stdin).get('status','unknown'))" 2>/dev/null || echo "unknown")
|
||||
|
||||
if [ "$STATUS" = "ok" ]; then
|
||||
echo "[$(date -Iseconds)] ✅ Health check passed: ${HEALTH}"
|
||||
echo "[$(date -Iseconds)] === Token Broker deploy SUCCESS ==="
|
||||
else
|
||||
echo "[$(date -Iseconds)] ❌ Health check failed: ${HEALTH}"
|
||||
echo "[$(date -Iseconds)] === Token Broker deploy FAILED ==="
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Step 7: Clean old backups (keep last 30)
|
||||
find /home/h3r7/backups/token-broker -name "*.py" -mtime +30 -delete
|
||||
@@ -18,14 +18,12 @@ SAAS_DIR = "/home/h3r7/turf_saas"
|
||||
# ─── SaaS Auth & API v1 blueprints ────────────────────────────────────────────
|
||||
try:
|
||||
from saas_auth import auth_bp
|
||||
from saas_api_v1 import api_v1_bp
|
||||
from api_v1.routes.ml_feedback import ml_feedback_bp
|
||||
from api_v1.routes.metrics import metrics_bp
|
||||
from saas_api_v1 import saas_api_v1_bp
|
||||
from api_v1 import register_api_v1
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(api_v1_bp)
|
||||
app.register_blueprint(ml_feedback_bp)
|
||||
app.register_blueprint(metrics_bp)
|
||||
app.register_blueprint(saas_api_v1_bp)
|
||||
register_api_v1(app)
|
||||
print("[portal] SaaS auth & API v1 blueprints registered ✅")
|
||||
except Exception as e:
|
||||
print(f"[portal] Warning: could not register SaaS blueprints: {e}")
|
||||
|
||||
@@ -13,7 +13,7 @@ from saas_auth import require_auth
|
||||
|
||||
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
||||
|
||||
api_v1_bp = Blueprint("api_v1", __name__, url_prefix="/api/v1")
|
||||
saas_api_v1_bp = Blueprint("saas_api_v1", __name__, url_prefix="/api/v1")
|
||||
|
||||
|
||||
def get_db():
|
||||
@@ -30,7 +30,7 @@ def plan_allows(user_plan: str, required: str) -> bool:
|
||||
# ─── Stats ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@api_v1_bp.route("/stats/summary", methods=["GET"])
|
||||
@saas_api_v1_bp.route("/stats/summary", methods=["GET"])
|
||||
@require_auth
|
||||
def stats_summary():
|
||||
"""GET /api/v1/stats/summary — résumé dashboard."""
|
||||
@@ -94,7 +94,7 @@ def stats_summary():
|
||||
# ─── Predictions ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@api_v1_bp.route("/predictions/today", methods=["GET"])
|
||||
@saas_api_v1_bp.route("/predictions/today", methods=["GET"])
|
||||
@require_auth
|
||||
def predictions_today():
|
||||
"""GET /api/v1/predictions/today — prédictions du jour selon le plan."""
|
||||
@@ -149,7 +149,7 @@ def predictions_today():
|
||||
return jsonify({"error": str(e), "predictions": []}), 200
|
||||
|
||||
|
||||
@api_v1_bp.route("/predictions/race/<race_label>", methods=["GET"])
|
||||
@saas_api_v1_bp.route("/predictions/race/<race_label>", methods=["GET"])
|
||||
@require_auth
|
||||
def predictions_race(race_label):
|
||||
"""GET /api/v1/predictions/race/<label> — prédictions d'une course."""
|
||||
@@ -187,7 +187,7 @@ def predictions_race(race_label):
|
||||
# ─── Value Bets ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@api_v1_bp.route("/value-bets/today", methods=["GET"])
|
||||
@saas_api_v1_bp.route("/value-bets/today", methods=["GET"])
|
||||
@require_auth
|
||||
def value_bets_today():
|
||||
"""GET /api/v1/value-bets/today — value bets (Premium+)."""
|
||||
@@ -220,7 +220,7 @@ def value_bets_today():
|
||||
# ─── Export ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@api_v1_bp.route("/export/csv", methods=["GET"])
|
||||
@saas_api_v1_bp.route("/export/csv", methods=["GET"])
|
||||
@require_auth
|
||||
def export_csv():
|
||||
"""GET /api/v1/export/csv — export CSV (Pro only)."""
|
||||
@@ -257,15 +257,13 @@ def export_csv():
|
||||
)
|
||||
|
||||
|
||||
# ─── Billing Blueprint (Stripe) + JWT init — HRT-49 ─────────────────────────
|
||||
# Registers /api/v1/billing/* routes via nested Blueprint (Flask 2.0+)
|
||||
# Also initializes JWTManager on the Flask app (required for jwt_required_middleware)
|
||||
# ─── JWT init — HRT-49 ────────────────────────────────────────────────────────
|
||||
# Initialize JWTManager on the Flask app (required for jwt_required_middleware)
|
||||
# Called when saas_api_v1_bp is registered (portal_server.py)
|
||||
try:
|
||||
from flask_jwt_extended import JWTManager
|
||||
from api_v1.routes.billing import billing_bp
|
||||
|
||||
# Initialize JWTManager on the Flask app when api_v1_bp is registered
|
||||
@api_v1_bp.record_once
|
||||
@saas_api_v1_bp.record_once
|
||||
def _init_jwt(state):
|
||||
app = state.app
|
||||
if not app.config.get("JWT_SECRET_KEY"):
|
||||
@@ -276,57 +274,6 @@ try:
|
||||
)
|
||||
if "flask_jwt_extended" not in app.extensions:
|
||||
JWTManager(app)
|
||||
|
||||
# Register billing blueprint with url_prefix='/billing'
|
||||
# (parent api_v1_bp has '/api/v1', so result is /api/v1/billing/*)
|
||||
api_v1_bp.register_blueprint(billing_bp, url_prefix="/billing")
|
||||
print("[saas_api_v1] Billing blueprint (Stripe) + JWT registered ✅")
|
||||
except Exception as _billing_err:
|
||||
print(f"[saas_api_v1] Warning: billing blueprint not loaded: {_billing_err}")
|
||||
|
||||
|
||||
# ─── Org Blueprint — HRT-82 ───────────────────────────────────────────────────
|
||||
# Registers /api/v1/org/* routes (Pro plan only, multi-compte max 5 users)
|
||||
try:
|
||||
from api_v1.routes.org import org_bp
|
||||
|
||||
@api_v1_bp.record_once
|
||||
def _register_org_bp(state):
|
||||
app = state.app
|
||||
app.register_blueprint(org_bp)
|
||||
|
||||
print("[saas_api_v1] Org blueprint (multi-compte Pro) registered ✅")
|
||||
except Exception as _org_err:
|
||||
print(f"[saas_api_v1] Warning: org blueprint not loaded: {_org_err}")
|
||||
|
||||
|
||||
# ─── User Blueprint — HRT-79 (Telegram) + HRT-80 (API Token + Webhook) ───────
|
||||
# Registers /api/v1/user/* routes (Premium+ for telegram, Pro for api-token/webhook)
|
||||
try:
|
||||
from api_v1.routes.user import user_bp
|
||||
from api_v1.routes.user_tokens import user_tokens_bp
|
||||
|
||||
@api_v1_bp.record_once
|
||||
def _register_user_bp(state):
|
||||
app = state.app
|
||||
app.register_blueprint(user_bp)
|
||||
app.register_blueprint(user_tokens_bp)
|
||||
|
||||
print('[saas_api_v1] User blueprint (Telegram config + API token + Webhook) registered ✅')
|
||||
except Exception as _user_err:
|
||||
print(f'[saas_api_v1] Warning: user blueprints not loaded: {_user_err}')
|
||||
|
||||
|
||||
# ─── History Blueprint — HRT-81 ───────────────────────────────────────────────
|
||||
# Registers /api/v1/history route (Free:7j, Premium:90j, Pro:illimité)
|
||||
try:
|
||||
from api_v1.routes.history import history_bp
|
||||
|
||||
@api_v1_bp.record_once
|
||||
def _register_history_bp(state):
|
||||
app = state.app
|
||||
app.register_blueprint(history_bp)
|
||||
|
||||
print('[saas_api_v1] History blueprint (plan-limited history) registered ✅')
|
||||
except Exception as _history_err:
|
||||
print(f'[saas_api_v1] Warning: history blueprint not loaded: {_history_err}')
|
||||
print("[saas_api_v1] JWT init registered ✅")
|
||||
except Exception as _jwt_err:
|
||||
print(f"[saas_api_v1] Warning: JWT init not loaded: {_jwt_err}")
|
||||
|
||||
10
services/token-broker/.env.example
Normal file
10
services/token-broker/.env.example
Normal file
@@ -0,0 +1,10 @@
|
||||
# Token Broker API — Configuration
|
||||
TOKEN_BROKER_PORT=8783
|
||||
TOKEN_BROKER_DB_HOST=127.0.0.1
|
||||
TOKEN_BROKER_DB_PORT=5434
|
||||
TOKEN_BROKER_DB_NAME=token_broker
|
||||
TOKEN_BROKER_DB_USER=token_broker
|
||||
TOKEN_BROKER_DB_PASSWORD=CHANGE_ME
|
||||
TOKEN_BROKER_JWT_SECRET=CHANGE_ME_GENERATE_64_HEX
|
||||
TOKEN_BROKER_ACCESS_EXPIRY=900
|
||||
TOKEN_BROKER_REFRESH_EXPIRY=2592000
|
||||
6
services/token-broker/requirements.txt
Normal file
6
services/token-broker/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
Flask==3.1.3
|
||||
flask-cors==5.0.1
|
||||
gunicorn==23.0.0
|
||||
psycopg2-binary==2.9.12
|
||||
PyJWT==2.10.1
|
||||
python-dotenv==1.1.0
|
||||
21
services/token-broker/token-broker.service
Normal file
21
services/token-broker/token-broker.service
Normal file
@@ -0,0 +1,21 @@
|
||||
[Unit]
|
||||
Description=Token Broker API (Port 8783)
|
||||
Documentation=https://portal-kolifee.duckdns.org
|
||||
After=network.target postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=h3r7
|
||||
WorkingDirectory=/home/h3r7/turf_saas/services/token-broker
|
||||
|
||||
EnvironmentFile=/home/h3r7/turf_saas/services/token-broker/.env
|
||||
Environment=PYTHONPATH=/home/h3r7/turf_saas
|
||||
Environment=FLASK_ENV=production
|
||||
|
||||
ExecStart=/home/h3r7/turf_saas/venv/bin/python3 /home/h3r7/turf_saas/services/token-broker/token_broker_api.py
|
||||
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
679
services/token-broker/token_broker_api.py
Normal file
679
services/token-broker/token_broker_api.py
Normal file
@@ -0,0 +1,679 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Token Broker API — JWT token management service
|
||||
Port: 8783 | DB: PostgreSQL 5434
|
||||
HRT-198 — Setup infra (PostgreSQL + Flask scaffold)
|
||||
|
||||
Endpoints:
|
||||
GET /health — Healthcheck
|
||||
POST /api/v1/tokens — Issue new token (create)
|
||||
GET /api/v1/tokens/:id — Get token by ID
|
||||
POST /api/v1/tokens/verify — Verify token
|
||||
POST /api/v1/tokens/revoke/:id — Revoke token
|
||||
GET /api/v1/tokens/user/:userId — List tokens for user
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
import hashlib
|
||||
import secrets
|
||||
import logging
|
||||
import logging.handlers
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from functools import wraps
|
||||
|
||||
from flask import Flask, request, jsonify, g
|
||||
from flask_cors import CORS
|
||||
|
||||
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] token-broker: %(name)s: %(message)s",
|
||||
handlers=[
|
||||
logging.StreamHandler(sys.stdout),
|
||||
logging.handlers.RotatingFileHandler(
|
||||
os.path.join(LOG_DIR, "token_broker.log"),
|
||||
maxBytes=5 * 1024 * 1024,
|
||||
backupCount=3,
|
||||
),
|
||||
],
|
||||
)
|
||||
logger = logging.getLogger("token_broker")
|
||||
|
||||
DB_HOST = os.environ.get("TOKEN_BROKER_DB_HOST", "127.0.0.1")
|
||||
DB_PORT = int(os.environ.get("TOKEN_BROKER_DB_PORT", "5434"))
|
||||
DB_NAME = os.environ.get("TOKEN_BROKER_DB_NAME", "token_broker")
|
||||
DB_USER = os.environ.get("TOKEN_BROKER_DB_USER", "token_broker")
|
||||
DB_PASSWORD = os.environ.get("TOKEN_BROKER_DB_PASSWORD", "")
|
||||
JWT_SECRET = os.environ.get(
|
||||
"TOKEN_BROKER_JWT_SECRET", "CHANGE_ME_" + secrets.token_hex(32)
|
||||
)
|
||||
ACCESS_TOKEN_EXPIRY = int(os.environ.get("TOKEN_BROKER_ACCESS_EXPIRY", "900"))
|
||||
REFRESH_TOKEN_EXPIRY = int(os.environ.get("TOKEN_BROKER_REFRESH_EXPIRY", "2592000"))
|
||||
|
||||
|
||||
def get_pg_conn():
|
||||
try:
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
conn = psycopg2.connect(
|
||||
host=DB_HOST,
|
||||
port=DB_PORT,
|
||||
dbname=DB_NAME,
|
||||
user=DB_USER,
|
||||
password=DB_PASSWORD,
|
||||
)
|
||||
conn.autocommit = True
|
||||
return conn
|
||||
except Exception as e:
|
||||
logger.error(f"PostgreSQL connection failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def init_db():
|
||||
conn = get_pg_conn()
|
||||
if not conn:
|
||||
logger.error("Cannot initialize DB — no connection")
|
||||
return False
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL DEFAULT 'default',
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
token_prefix TEXT NOT NULL,
|
||||
scopes TEXT[] DEFAULT '{}',
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ,
|
||||
last_used_at TIMESTAMPTZ,
|
||||
metadata JSONB DEFAULT '{}'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id INTEGER NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
token_prefix TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
replaced_by UUID
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS token_audit_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id INTEGER,
|
||||
action TEXT NOT NULL,
|
||||
token_prefix TEXT,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
details JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS clients (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
client_id TEXT NOT NULL UNIQUE,
|
||||
client_secret TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
redirect_uris TEXT[] DEFAULT '{}',
|
||||
scopes TEXT[] DEFAULT '{}',
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS providers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
provider_type TEXT NOT NULL DEFAULT 'oauth2',
|
||||
issuer_url TEXT,
|
||||
client_id TEXT,
|
||||
client_secret TEXT,
|
||||
scopes TEXT[] DEFAULT '{}',
|
||||
config JSONB DEFAULT '{}',
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS token_usage (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
token_id UUID,
|
||||
action TEXT NOT NULL DEFAULT 'verify',
|
||||
endpoint TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'success',
|
||||
response_time_ms INTEGER,
|
||||
ip_address TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON api_tokens(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_token_hash ON api_tokens(token_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_token_audit_log_user_id ON token_audit_log(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_token_audit_log_created_at ON token_audit_log(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_clients_client_id ON clients(client_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_providers_name ON providers(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_token_usage_user_id ON token_usage(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_token_usage_created_at ON token_usage(created_at);
|
||||
""")
|
||||
cur.close()
|
||||
conn.close()
|
||||
logger.info("Database tables initialized successfully")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Database initialization failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__)
|
||||
|
||||
app.config["JWT_SECRET"] = JWT_SECRET
|
||||
app.config["ACCESS_TOKEN_EXPIRY"] = ACCESS_TOKEN_EXPIRY
|
||||
app.config["REFRESH_TOKEN_EXPIRY"] = REFRESH_TOKEN_EXPIRY
|
||||
|
||||
CORS(app)
|
||||
register_routes(app)
|
||||
register_error_handlers(app)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def token_required(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if not auth_header.startswith("Bearer "):
|
||||
return jsonify({"error": "missing_token", "message": "Bearer token required"}), 401
|
||||
token = auth_header.split(" ", 1)[1]
|
||||
payload = verify_jwt_token(token)
|
||||
if not payload:
|
||||
return jsonify({"error": "invalid_token", "message": "Token invalid or expired"}), 401
|
||||
g.user_id = payload.get("user_id")
|
||||
g.token_id = payload.get("token_id")
|
||||
g.scopes = payload.get("scopes", [])
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
|
||||
def generate_token_pair(user_id, scopes=None, metadata=None):
|
||||
import jwt as pyjwt
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
access_payload = {
|
||||
"user_id": user_id,
|
||||
"token_id": str(uuid.uuid4()),
|
||||
"scopes": scopes or [],
|
||||
"type": "access",
|
||||
"iat": now,
|
||||
"exp": now + timedelta(seconds=ACCESS_TOKEN_EXPIRY),
|
||||
}
|
||||
access_token = pyjwt.encode(access_payload, JWT_SECRET, algorithm="HS256")
|
||||
|
||||
refresh_id = str(uuid.uuid4())
|
||||
refresh_raw = secrets.token_urlsafe(48)
|
||||
refresh_payload = {
|
||||
"user_id": user_id,
|
||||
"refresh_id": refresh_id,
|
||||
"token_hash": hashlib.sha256(refresh_raw.encode()).hexdigest(),
|
||||
"type": "refresh",
|
||||
"iat": now,
|
||||
"exp": now + timedelta(seconds=REFRESH_TOKEN_EXPIRY),
|
||||
}
|
||||
refresh_token = pyjwt.encode(refresh_payload, JWT_SECRET, algorithm="HS256")
|
||||
|
||||
store_refresh_token(user_id, refresh_id, refresh_payload["token_hash"])
|
||||
log_audit(user_id, "token_issued", access_payload["token_id"][:8])
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_raw,
|
||||
"expires_in": ACCESS_TOKEN_EXPIRY,
|
||||
"token_type": "Bearer",
|
||||
}
|
||||
|
||||
|
||||
def verify_jwt_token(token):
|
||||
import jwt as pyjwt
|
||||
try:
|
||||
payload = pyjwt.decode(token, JWT_SECRET, algorithms=["HS256"])
|
||||
if payload.get("type") == "refresh":
|
||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||
conn = get_pg_conn()
|
||||
if conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT revoked FROM refresh_tokens WHERE token_hash = %s AND expires_at > NOW()",
|
||||
(token_hash,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
conn.close()
|
||||
if not row or row[0]:
|
||||
return None
|
||||
return payload
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def store_refresh_token(user_id, refresh_id, token_hash):
|
||||
conn = get_pg_conn()
|
||||
if not conn:
|
||||
return
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""INSERT INTO refresh_tokens (id, user_id, token_hash, token_prefix, expires_at)
|
||||
VALUES (%s, %s, %s, %s, NOW() + INTERVAL '30 days')""",
|
||||
(refresh_id, user_id, token_hash, token_hash[:8]),
|
||||
)
|
||||
cur.close()
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to store refresh token: {e}")
|
||||
|
||||
|
||||
def log_audit(user_id, action, token_prefix, details=None):
|
||||
conn = get_pg_conn()
|
||||
if not conn:
|
||||
return
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""INSERT INTO token_audit_log (user_id, action, token_prefix, ip_address, user_agent, details)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)""",
|
||||
(
|
||||
user_id,
|
||||
action,
|
||||
token_prefix,
|
||||
request.remote_addr if request else None,
|
||||
request.user_agent.string if request and request.user_agent else None,
|
||||
"{}" if details is None else details,
|
||||
),
|
||||
)
|
||||
cur.close()
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def register_routes(app):
|
||||
@app.route("/health", methods=["GET"])
|
||||
def healthcheck():
|
||||
conn = get_pg_conn()
|
||||
db_ok = conn is not None
|
||||
if conn:
|
||||
conn.close()
|
||||
return jsonify({
|
||||
"status": "ok" if db_ok else "degraded",
|
||||
"service": "token-broker",
|
||||
"version": "1.0.0",
|
||||
"database": "connected" if db_ok else "disconnected",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}), 200 if db_ok else 503
|
||||
|
||||
@app.route("/api/v1/tokens", methods=["POST"])
|
||||
@token_required
|
||||
def issue_token():
|
||||
data = request.get_json(silent=True) or {}
|
||||
user_id = g.user_id
|
||||
scopes = data.get("scopes", [])
|
||||
name = data.get("name", "default")
|
||||
metadata = data.get("metadata", {})
|
||||
|
||||
conn = get_pg_conn()
|
||||
if not conn:
|
||||
return jsonify({"error": "db_error", "message": "Database unavailable"}), 503
|
||||
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
import psycopg2.extras
|
||||
raw_token = "tb_" + secrets.token_urlsafe(32)
|
||||
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
|
||||
token_prefix = raw_token[:12] + "..."
|
||||
|
||||
cur.execute(
|
||||
"""INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, scopes, metadata)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
RETURNING id, created_at, expires_at""",
|
||||
(user_id, name, token_hash, token_prefix, scopes,
|
||||
psycopg2.extras.Json(metadata)),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
log_audit(user_id, "api_token_created", token_prefix)
|
||||
return jsonify({
|
||||
"id": str(row[0]),
|
||||
"token": raw_token,
|
||||
"name": name,
|
||||
"scopes": scopes,
|
||||
"created_at": row[1].isoformat(),
|
||||
"expires_at": row[2].isoformat() if row[2] else None,
|
||||
}), 201
|
||||
except Exception as e:
|
||||
logger.error(f"Token creation failed: {e}")
|
||||
return jsonify({"error": "creation_failed", "message": str(e)}), 500
|
||||
|
||||
@app.route("/api/v1/tokens/verify", methods=["POST"])
|
||||
def verify_token():
|
||||
data = request.get_json(silent=True) or {}
|
||||
raw_token = data.get("token", "")
|
||||
|
||||
if not raw_token:
|
||||
return jsonify({"valid": False, "error": "token_required"}), 400
|
||||
|
||||
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
|
||||
conn = get_pg_conn()
|
||||
if not conn:
|
||||
return jsonify({"valid": False, "error": "db_error"}), 503
|
||||
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""SELECT id, user_id, name, scopes, is_active, created_at, expires_at, last_used_at
|
||||
FROM api_tokens
|
||||
WHERE token_hash = %s""",
|
||||
(token_hash,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
if not row:
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({"valid": False, "error": "token_not_found"}), 404
|
||||
|
||||
token_id, user_id, name, scopes, is_active, created_at, expires_at, last_used_at = row
|
||||
|
||||
if not is_active:
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({"valid": False, "error": "token_revoked"}), 403
|
||||
|
||||
if expires_at and expires_at < datetime.now(timezone.utc):
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({"valid": False, "error": "token_expired"}), 403
|
||||
|
||||
cur.execute(
|
||||
"UPDATE api_tokens SET last_used_at = NOW() WHERE id = %s",
|
||||
(token_id,),
|
||||
)
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return jsonify({
|
||||
"valid": True,
|
||||
"token_id": str(token_id),
|
||||
"user_id": user_id,
|
||||
"name": name,
|
||||
"scopes": scopes,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Token verification failed: {e}")
|
||||
return jsonify({"valid": False, "error": "verification_failed"}), 500
|
||||
|
||||
@app.route("/api/v1/tokens/<token_id>", methods=["GET"])
|
||||
@token_required
|
||||
def get_token(token_id):
|
||||
conn = get_pg_conn()
|
||||
if not conn:
|
||||
return jsonify({"error": "db_error"}), 503
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""SELECT id, user_id, name, scopes, is_active, created_at, expires_at, last_used_at, metadata
|
||||
FROM api_tokens WHERE id = %s AND user_id = %s""",
|
||||
(token_id, g.user_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
if not row:
|
||||
return jsonify({"error": "not_found"}), 404
|
||||
|
||||
return jsonify({
|
||||
"id": str(row[0]),
|
||||
"user_id": row[1],
|
||||
"name": row[2],
|
||||
"scopes": row[3],
|
||||
"is_active": row[4],
|
||||
"created_at": row[5].isoformat(),
|
||||
"expires_at": row[6].isoformat() if row[6] else None,
|
||||
"last_used_at": row[7].isoformat() if row[7] else None,
|
||||
"metadata": row[8] if row[8] else {},
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Get token failed: {e}")
|
||||
return jsonify({"error": "query_failed"}), 500
|
||||
|
||||
@app.route("/api/v1/tokens/revoke/<token_id>", methods=["POST"])
|
||||
@token_required
|
||||
def revoke_token(token_id):
|
||||
conn = get_pg_conn()
|
||||
if not conn:
|
||||
return jsonify({"error": "db_error"}), 503
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""UPDATE api_tokens SET is_active = FALSE WHERE id = %s AND user_id = %s
|
||||
RETURNING id, name""",
|
||||
(token_id, g.user_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
if not row:
|
||||
return jsonify({"error": "not_found"}), 404
|
||||
|
||||
log_audit(g.user_id, "api_token_revoked", str(row[0])[:8])
|
||||
return jsonify({"status": "revoked", "token_id": str(row[0])})
|
||||
except Exception as e:
|
||||
logger.error(f"Revoke token failed: {e}")
|
||||
return jsonify({"error": "revoke_failed"}), 500
|
||||
|
||||
@app.route("/api/v1/tokens/user/<int:user_id>", methods=["GET"])
|
||||
@token_required
|
||||
def list_user_tokens(user_id):
|
||||
if g.user_id != user_id and "admin" not in g.scopes:
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
|
||||
conn = get_pg_conn()
|
||||
if not conn:
|
||||
return jsonify({"error": "db_error"}), 503
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""SELECT id, user_id, name, scopes, is_active, created_at, expires_at, last_used_at
|
||||
FROM api_tokens
|
||||
WHERE user_id = %s
|
||||
ORDER BY created_at DESC""",
|
||||
(user_id,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
tokens = []
|
||||
for row in rows:
|
||||
tokens.append({
|
||||
"id": str(row[0]),
|
||||
"user_id": row[1],
|
||||
"name": row[2],
|
||||
"scopes": row[3],
|
||||
"is_active": row[4],
|
||||
"created_at": row[5].isoformat(),
|
||||
"expires_at": row[6].isoformat() if row[6] else None,
|
||||
"last_used_at": row[7].isoformat() if row[7] else None,
|
||||
})
|
||||
return jsonify({"tokens": tokens, "total": len(tokens)})
|
||||
except Exception as e:
|
||||
logger.error(f"List tokens failed: {e}")
|
||||
return jsonify({"error": "query_failed"}), 500
|
||||
|
||||
@app.route("/api/v1/auth/token", methods=["POST"])
|
||||
def exchange_token():
|
||||
data = request.get_json(silent=True) or {}
|
||||
grant_type = data.get("grant_type", "client_credentials")
|
||||
raw_token = data.get("client_token", "") or data.get("token", "")
|
||||
refresh_raw = data.get("refresh_token", "")
|
||||
|
||||
if grant_type == "refresh_token" and refresh_raw:
|
||||
return refresh_access_token(refresh_raw)
|
||||
|
||||
if not raw_token:
|
||||
return jsonify({"error": "invalid_request", "message": "client_token required"}), 400
|
||||
|
||||
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
|
||||
conn = get_pg_conn()
|
||||
if not conn:
|
||||
return jsonify({"error": "db_error"}), 503
|
||||
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""SELECT id, user_id, scopes, is_active, expires_at
|
||||
FROM api_tokens WHERE token_hash = %s""",
|
||||
(token_hash,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
if not row:
|
||||
return jsonify({"error": "invalid_token"}), 401
|
||||
if not row[3]:
|
||||
return jsonify({"error": "token_revoked"}), 403
|
||||
if row[4] and row[4] < datetime.now(timezone.utc):
|
||||
return jsonify({"error": "token_expired"}), 403
|
||||
|
||||
token_pair = generate_token_pair(row[1], row[2])
|
||||
return jsonify(token_pair), 200
|
||||
except Exception as e:
|
||||
logger.error(f"Token exchange failed: {e}")
|
||||
return jsonify({"error": "exchange_failed"}), 500
|
||||
|
||||
@app.route("/api/v1/auth/refresh", methods=["POST"])
|
||||
def refresh_token_endpoint():
|
||||
data = request.get_json(silent=True) or {}
|
||||
refresh_raw = data.get("refresh_token", "")
|
||||
return refresh_access_token(refresh_raw)
|
||||
|
||||
@app.route("/api/v1/auth/revoke", methods=["POST"])
|
||||
@token_required
|
||||
def revoke_refresh_token():
|
||||
data = request.get_json(silent=True) or {}
|
||||
refresh_raw = data.get("refresh_token", "")
|
||||
|
||||
if not refresh_raw:
|
||||
return jsonify({"error": "refresh_token_required"}), 400
|
||||
|
||||
token_hash = hashlib.sha256(refresh_raw.encode()).hexdigest()
|
||||
conn = get_pg_conn()
|
||||
if not conn:
|
||||
return jsonify({"error": "db_error"}), 503
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"UPDATE refresh_tokens SET revoked = TRUE, revoked_at = NOW() WHERE token_hash = %s",
|
||||
(token_hash,),
|
||||
)
|
||||
cur.close()
|
||||
conn.close()
|
||||
log_audit(g.user_id, "refresh_token_revoked", token_hash[:8])
|
||||
return jsonify({"status": "revoked"})
|
||||
except Exception as e:
|
||||
logger.error(f"Revoke refresh token failed: {e}")
|
||||
return jsonify({"error": "revoke_failed"}), 500
|
||||
|
||||
|
||||
def refresh_access_token(refresh_raw):
|
||||
if not refresh_raw:
|
||||
return jsonify({"error": "refresh_token_required"}), 400
|
||||
|
||||
token_hash = hashlib.sha256(refresh_raw.encode()).hexdigest()
|
||||
conn = get_pg_conn()
|
||||
if not conn:
|
||||
return jsonify({"error": "db_error"}), 503
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""SELECT id, user_id, revoked, expires_at
|
||||
FROM refresh_tokens WHERE token_hash = %s""",
|
||||
(token_hash,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
if not row:
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({"error": "invalid_token"}), 401
|
||||
if row[2]:
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({"error": "token_revoked"}), 403
|
||||
if row[3] < datetime.now(timezone.utc):
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({"error": "token_expired"}), 403
|
||||
|
||||
refresh_id = row[0]
|
||||
user_id = row[1]
|
||||
|
||||
cur.execute(
|
||||
"UPDATE refresh_tokens SET revoked = TRUE, revoked_at = NOW() WHERE id = %s",
|
||||
(refresh_id,),
|
||||
)
|
||||
|
||||
pairs = generate_token_pair(user_id)
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify(pairs), 200
|
||||
except Exception as e:
|
||||
logger.error(f"Refresh token failed: {e}")
|
||||
return jsonify({"error": "refresh_failed"}), 500
|
||||
|
||||
|
||||
def register_error_handlers(app):
|
||||
@app.errorhandler(404)
|
||||
def not_found(e):
|
||||
return jsonify({"error": "not_found", "message": "Route not found"}), 404
|
||||
|
||||
@app.errorhandler(405)
|
||||
def method_not_allowed(e):
|
||||
return jsonify({"error": "method_not_allowed", "message": "Method not allowed"}), 405
|
||||
|
||||
@app.errorhandler(500)
|
||||
def internal_error(e):
|
||||
logger.error(f"Internal error: {e}")
|
||||
return jsonify({"error": "internal_error", "message": "Internal server error"}), 500
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logger.info("=" * 60)
|
||||
logger.info("Token Broker API starting...")
|
||||
logger.info(f"DB: {DB_HOST}:{DB_PORT}/{DB_NAME}")
|
||||
logger.info(f"Port: {os.environ.get('TOKEN_BROKER_PORT', '8783')}")
|
||||
logger.info("=" * 60)
|
||||
|
||||
init_db()
|
||||
|
||||
port = int(os.environ.get("TOKEN_BROKER_PORT", "8783"))
|
||||
debug = os.environ.get("FLASK_ENV", "production") == "development"
|
||||
app = create_app()
|
||||
app.run(host="0.0.0.0", port=port, debug=debug)
|
||||
@@ -107,6 +107,34 @@ def run_analytics():
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
def run_sync_turf_db():
|
||||
"""Synchronise turf.db vers turf_saas.db"""
|
||||
logger.info("🔄 [SCHEDULER] Sync turf.db -> turf_saas.db...")
|
||||
try:
|
||||
import subprocess
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
"python3",
|
||||
"/home/h3r7/turf_saas/sync_turf_db.py",
|
||||
"--date",
|
||||
datetime.now().strftime("%Y-%m-%d"),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
logger.info("✅ [SCHEDULER] Sync turf.db terminé")
|
||||
else:
|
||||
logger.error(f"❌ [SCHEDULER] Sync turf.db échoué: {result.stderr}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ [SCHEDULER] Erreur sync turf.db: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
def get_todays_race_time():
|
||||
"""Récupère l'heure de la course principale du jour depuis la DB
|
||||
Returns: timestamp en ms ou None
|
||||
@@ -315,6 +343,16 @@ def main():
|
||||
schedule.every().day.at("20:00").do(run_results).tag("results", "daily_fallback")
|
||||
schedule.every().day.at("19:00").do(run_scraper).tag("scraper", "late_evening")
|
||||
|
||||
# Sync turf.db -> turf_saas.db (2x/jour: post-scraping + post-cotes)
|
||||
schedule.every().day.at("11:00").do(run_sync_turf_db).tag("sync", "post_scraping")
|
||||
schedule.every().day.at("17:00").do(run_sync_turf_db).tag("sync", "post_cotes")
|
||||
|
||||
# ML Cache: populate ml_predictions_cache après chaque sync
|
||||
schedule.every().day.at("11:35").do(run_ml_cache).tag("ml_cache", "post_sync_am")
|
||||
schedule.every().day.at("17:35").do(run_ml_cache).tag("ml_cache", "post_sync_pm")
|
||||
schedule.every().day.at("09:30").do(run_ml_cache).tag("ml_cache", "morning")
|
||||
schedule.every().day.at("13:30").do(run_ml_cache).tag("ml_cache", "pre_race")
|
||||
|
||||
schedule.every().sunday.at("02:00").do(run_ml).tag("ml", "weekly")
|
||||
schedule.every().wednesday.at("02:00").do(run_ml).tag("ml", "midweek")
|
||||
|
||||
@@ -335,6 +373,200 @@ def main():
|
||||
time.sleep(30)
|
||||
|
||||
|
||||
def run_ml_cache():
|
||||
"""Populate ml_predictions_cache with ensemble (predict_v2) predictions"""
|
||||
logger.info("🤖 [SCHEDULER] Mise à jour cache prédictions ML (ensemble)...")
|
||||
try:
|
||||
os.chdir("/home/h3r7/turf_saas")
|
||||
import predict_v2
|
||||
|
||||
model = predict_v2.load_ensemble()
|
||||
if model is None:
|
||||
logger.warning("⚠️ [SCHEDULER] Ensemble model not available, skipping")
|
||||
return
|
||||
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
rows = conn.execute("""
|
||||
SELECT p.*, c.distance, c.discipline, c.specialite,
|
||||
c.nb_declares_partants, c.montant_prix, c.penetrometre_intitule,
|
||||
c.libelle as course_libelle, c.libelle_court as hippodrome,
|
||||
c.heure_depart_str, c.parcours
|
||||
FROM pmu_partants p
|
||||
LEFT JOIN pmu_courses c ON p.date_programme = c.date_programme
|
||||
AND p.num_reunion = c.num_reunion AND p.num_course = c.num_course
|
||||
WHERE p.date_programme = ?
|
||||
ORDER BY p.num_reunion, p.num_course, p.num_pmu
|
||||
""", (today,)).fetchall()
|
||||
|
||||
if not rows:
|
||||
logger.info("ℹ️ [SCHEDULER] No partants today, skipping ML cache")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
partants = [dict(r) for r in rows]
|
||||
|
||||
course_lookup = {}
|
||||
for p in partants:
|
||||
key = (p["num_reunion"], p["num_course"])
|
||||
if key not in course_lookup:
|
||||
course_lookup[key] = {
|
||||
"libelle": p.get("course_libelle", ""),
|
||||
"libelle_court": p.get("hippodrome", ""),
|
||||
"discipline": p.get("discipline", ""),
|
||||
"distance": p.get("distance", 0),
|
||||
"heure_depart_str": p.get("heure_depart_str", ""),
|
||||
}
|
||||
|
||||
odds_by_horse = {}
|
||||
for p in partants:
|
||||
odds_by_horse[(p["num_reunion"], p["num_course"], p["num_pmu"])] = p.get("cote_direct", 0)
|
||||
|
||||
preds = predict_v2.predict_top3(partants, model=model)
|
||||
if not preds:
|
||||
logger.warning("⚠️ [SCHEDULER] No predictions generated")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
enriched = []
|
||||
for p in preds:
|
||||
key = (p.get("num_reunion"), p.get("num_course"))
|
||||
ci = course_lookup.get(key, {})
|
||||
odds_key = (p.get("num_reunion"), p.get("num_course"), p.get("num_pmu"))
|
||||
enriched.append({
|
||||
"num_reunion": p.get("num_reunion"),
|
||||
"num_course": p.get("num_course"),
|
||||
"horse_name": p.get("horse_name"),
|
||||
"horse_number": p.get("num_pmu"),
|
||||
"odds": odds_by_horse.get(odds_key, 0),
|
||||
"prob_top1": p.get("prob_top1"),
|
||||
"prob_top3": p.get("prob_top3"),
|
||||
"ml_score": p.get("ml_score"),
|
||||
"recommendation": p.get("recommendation"),
|
||||
"is_value_bet": p.get("is_value_bet", 0),
|
||||
"is_outlier": 0,
|
||||
"race_label": f"R{p.get('num_reunion', 0)}C{p.get('num_course', 0)}",
|
||||
"race_name": ci.get("libelle", ""),
|
||||
"hippodrome": ci.get("libelle_court", ""),
|
||||
"discipline": ci.get("discipline", ""),
|
||||
"distance": ci.get("distance", 0),
|
||||
"heure": ci.get("heure_depart_str", ""),
|
||||
})
|
||||
|
||||
# Calculate risques per race (same logic as dashboard_api.calculate_risque)
|
||||
from collections import defaultdict
|
||||
race_horses = defaultdict(list)
|
||||
for p in enriched:
|
||||
rkey = (p.get("num_reunion"), p.get("num_course"))
|
||||
race_horses[rkey].append({
|
||||
"odds": p.get("odds", 999),
|
||||
"ml_score": p.get("ml_score", 0),
|
||||
"prob_top1": p.get("prob_top1", 0),
|
||||
"prob_top3": p.get("prob_top3", 0),
|
||||
})
|
||||
|
||||
race_risque = {}
|
||||
for rkey, partants_list in race_horses.items():
|
||||
label, score = _calc_risque(partants_list)
|
||||
race_risque[rkey] = (label or "neutral", score or 50)
|
||||
|
||||
# Ensure table exists with all columns
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS ml_predictions_cache (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
date TEXT NOT NULL, num_reunion INTEGER, num_course INTEGER,
|
||||
horse_name TEXT, horse_number INTEGER, odds REAL,
|
||||
prob_top1 REAL, prob_top3 REAL, ml_score REAL,
|
||||
recommendation TEXT, is_value_bet INTEGER DEFAULT 0,
|
||||
is_outlier INTEGER DEFAULT 0, race_label TEXT, race_name TEXT,
|
||||
hippodrome TEXT, discipline TEXT, distance REAL, heure TEXT,
|
||||
model_version TEXT DEFAULT 'xgboost_v1',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
risque_label TEXT DEFAULT 'neutral', risque_score INTEGER DEFAULT 50,
|
||||
UNIQUE(date, num_reunion, num_course, horse_name)
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_ml_cache_date ON ml_predictions_cache(date)")
|
||||
|
||||
try:
|
||||
conn.execute("ALTER TABLE ml_predictions_cache ADD COLUMN risque_label TEXT DEFAULT 'neutral'")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
conn.execute("ALTER TABLE ml_predictions_cache ADD COLUMN risque_score INTEGER DEFAULT 50")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
conn.execute("DELETE FROM ml_predictions_cache WHERE date = ?", (today,))
|
||||
|
||||
for p in enriched:
|
||||
rkey = (p.get("num_reunion"), p.get("num_course"))
|
||||
rl, rs = race_risque.get(rkey, ("neutral", 50))
|
||||
conn.execute("""
|
||||
INSERT INTO ml_predictions_cache
|
||||
(date, num_reunion, num_course, horse_name, horse_number, odds,
|
||||
prob_top1, prob_top3, ml_score, recommendation, is_value_bet, is_outlier,
|
||||
race_label, race_name, hippodrome, discipline, distance, heure,
|
||||
risque_label, risque_score, model_version)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
""", (
|
||||
today, p.get("num_reunion"), p.get("num_course"),
|
||||
p.get("horse_name"), p.get("horse_number"), p.get("odds"),
|
||||
p.get("prob_top1"), p.get("prob_top3"), p.get("ml_score"),
|
||||
p.get("recommendation"), p.get("is_value_bet", 0), p.get("is_outlier", 0),
|
||||
p.get("race_label"), p.get("race_name"), p.get("hippodrome"),
|
||||
p.get("discipline"), p.get("distance"), p.get("heure"),
|
||||
rl, rs, "ensemble_v1",
|
||||
))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
logger.info(f"✅ [SCHEDULER] ML cache mis à jour: {len(enriched)} prédictions pour {today}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ [SCHEDULER] Erreur ML cache: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
def _calc_risque(partants_list):
|
||||
"""Same logic as dashboard_api.calculate_risque — kept local to avoid import side effects"""
|
||||
if not partants_list:
|
||||
return None, None
|
||||
sorted_p = sorted(
|
||||
partants_list,
|
||||
key=lambda x: x.get("ml_score") or x.get("prob_top1") or 0,
|
||||
reverse=True,
|
||||
)
|
||||
top1_score = sorted_p[0].get("ml_score") or sorted_p[0].get("prob_top1") or 0
|
||||
top2_score = (
|
||||
sorted_p[1].get("ml_score") or sorted_p[1].get("prob_top1") or 0
|
||||
if len(sorted_p) > 1 else 0
|
||||
)
|
||||
gap_1_2 = top1_score - top2_score
|
||||
nb_dangerous = sum(1 for p in sorted_p if (p.get("ml_score") or 0) > 40)
|
||||
odds_fav = sorted(partants_list, key=lambda x: x.get("odds") or 999)
|
||||
fav_odds = odds_fav[0].get("odds") or 999 if odds_fav else 999
|
||||
fav_ml = (
|
||||
odds_fav[0].get("ml_score") or odds_fav[0].get("prob_top1") or 0
|
||||
if odds_fav else 0
|
||||
)
|
||||
fav_surprise = fav_odds < 5 and fav_ml < 25
|
||||
if top1_score >= 65 and gap_1_2 >= 20:
|
||||
score = min(100, int(50 + gap_1_2 * 1.5))
|
||||
return "safe", score
|
||||
if fav_surprise:
|
||||
return "trap", max(10, int(35 - (25 - fav_ml)))
|
||||
if nb_dangerous >= 4 and top1_score < 70:
|
||||
return "trap", max(10, int(40 - nb_dangerous * 2))
|
||||
if gap_1_2 < 8 and top2_score > 45:
|
||||
return "trap", max(15, int(30 + gap_1_2))
|
||||
score = min(64, max(35, int(35 + gap_1_2 * 1.2)))
|
||||
return "neutral", score
|
||||
|
||||
|
||||
def run_metrics_alerts():
|
||||
"""Verifie les metriques du jour et envoie une alerte email si ROI > 1.0€"""
|
||||
logger.info("📧 [SCHEDULER] Vérification alertes métriques...")
|
||||
|
||||
Reference in New Issue
Block a user