mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-29 09:14:33 +00:00
960 lines
33 KiB
Python
960 lines
33 KiB
Python
"""SecuBox Auth API - OAuth2 + Vouchers + Sessions with Enhanced Monitoring"""
|
|
from fastapi import FastAPI, APIRouter, Depends, HTTPException, BackgroundTasks
|
|
from pydantic import BaseModel, Field, field_validator
|
|
from secubox_core.auth import router as auth_router, require_jwt, create_token, set_session_callback
|
|
from secubox_core.config import get_config
|
|
import json
|
|
import os
|
|
import secrets
|
|
import time
|
|
import threading
|
|
import asyncio
|
|
import hashlib
|
|
import hmac
|
|
import httpx
|
|
import subprocess
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
from typing import Dict, Any, List, Optional
|
|
|
|
app = FastAPI(title="secubox-auth", version="2.0.0", root_path="/api/v1/auth")
|
|
|
|
# ══════════════════════════════════════════════════════════════════
|
|
# Health Check Endpoint (public, no auth)
|
|
# ══════════════════════════════════════════════════════════════════
|
|
|
|
@app.get("/health")
|
|
async def health_check():
|
|
"""Public health check endpoint for sidebar status."""
|
|
return {"status": "ok", "module": "deb"}
|
|
|
|
# ──────────────────────────────────────────────────────────────────────
|
|
# Task 13: branching login, MFA, TOTP enroll/confirm, set-password
|
|
# Inserted BEFORE the legacy auth_router mount so the overriding
|
|
# _login_router (mounted AFTER) takes precedence on /auth/login.
|
|
# ──────────────────────────────────────────────────────────────────────
|
|
from secubox_core import user_store
|
|
from secubox_core.auth import (
|
|
set_session_validator,
|
|
_emit_session_event,
|
|
_decode_token as _jwt_decode,
|
|
)
|
|
|
|
# Make secubox-users engine importable (in-process path, no IPC).
|
|
# We cannot use `from api import engine` because Python resolves "api" to the
|
|
# secubox-auth api package (the one we're inside). Instead we register the
|
|
# secubox-users api/ directory as a *separate* package named `_users_api` in
|
|
# sys.modules so that the relative imports inside engine.py (`from . import totp`)
|
|
# resolve correctly.
|
|
import importlib.util as _ilu
|
|
import sys as _sys
|
|
import types as _types
|
|
|
|
|
|
def _bootstrap_users_api_package() -> str:
|
|
"""Register secubox-users/api as '_users_api' package; return its root dir."""
|
|
candidates = [
|
|
Path("/usr/lib/secubox/users/api"),
|
|
Path(__file__).resolve().parents[3] / "packages" / "secubox-users" / "api",
|
|
]
|
|
pkg_root = next((p for p in candidates if (p / "engine.py").exists()), None)
|
|
if pkg_root is None:
|
|
raise ImportError("secubox-users api/ not found in any candidate path")
|
|
|
|
pkg_name = "_users_api"
|
|
if pkg_name not in _sys.modules:
|
|
# Create a package object pointing at the discovered directory.
|
|
pkg = _types.ModuleType(pkg_name)
|
|
pkg.__path__ = [str(pkg_root)]
|
|
pkg.__package__ = pkg_name
|
|
pkg.__spec__ = _ilu.spec_from_file_location(
|
|
pkg_name, str(pkg_root / "__init__.py"),
|
|
submodule_search_locations=[str(pkg_root)],
|
|
)
|
|
_sys.modules[pkg_name] = pkg
|
|
# Execute __init__.py if it exists.
|
|
init_py = pkg_root / "__init__.py"
|
|
if init_py.exists():
|
|
spec = _ilu.spec_from_file_location(pkg_name, str(init_py),
|
|
submodule_search_locations=[str(pkg_root)])
|
|
mod = _ilu.module_from_spec(spec) # type: ignore[arg-type]
|
|
_sys.modules[pkg_name] = mod
|
|
spec.loader.exec_module(mod) # type: ignore[union-attr]
|
|
|
|
return pkg_name
|
|
|
|
|
|
def _load_users_submod(pkg_name: str, name: str) -> object:
|
|
full_name = f"{pkg_name}.{name}"
|
|
if full_name in _sys.modules:
|
|
return _sys.modules[full_name]
|
|
pkg_root = Path(_sys.modules[pkg_name].__path__[0])
|
|
spec = _ilu.spec_from_file_location(full_name, str(pkg_root / f"{name}.py"),
|
|
submodule_search_locations=[])
|
|
mod = _ilu.module_from_spec(spec) # type: ignore[arg-type]
|
|
mod.__package__ = pkg_name
|
|
_sys.modules[full_name] = mod
|
|
spec.loader.exec_module(mod) # type: ignore[union-attr]
|
|
return mod
|
|
|
|
|
|
_users_api_pkg = _bootstrap_users_api_package()
|
|
_users_engine_mod = _load_users_submod(_users_api_pkg, "engine")
|
|
_users_totp_mod = _load_users_submod(_users_api_pkg, "totp")
|
|
from api.totp_pending import PendingStore
|
|
|
|
_DATA_DIR = Path(os.environ.get("SECUBOX_AUTH_DATA_DIR", "/var/lib/secubox/auth"))
|
|
try:
|
|
_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
except PermissionError:
|
|
# Running as non-root in dev/test — caller is responsible for env-overriding to a writable path.
|
|
pass
|
|
__SESSIONS_FILE = Path(os.environ.get("SECUBOX_AUTH_SESSIONS", str(_DATA_DIR / "sessions.json")))
|
|
_AUDIT_FILE = Path(os.environ.get("SECUBOX_AUTH_AUDIT", str(_DATA_DIR / "audit.log")))
|
|
_TOTP_PENDING_FILE = Path(os.environ.get("SECUBOX_AUTH_TOTP_PENDING", str(_DATA_DIR / "totp-pending.json")))
|
|
_USERS_FILE = Path(os.environ.get("USERS_FILE", "/etc/secubox/users.json"))
|
|
|
|
_pending = PendingStore(_TOTP_PENDING_FILE, ttl_seconds=900)
|
|
_users_engine = _users_engine_mod.Engine(users_path=_USERS_FILE)
|
|
|
|
|
|
def _read_sessions() -> list:
|
|
if not __SESSIONS_FILE.exists():
|
|
return []
|
|
try:
|
|
return json.loads(__SESSIONS_FILE.read_text())
|
|
except Exception:
|
|
return []
|
|
|
|
|
|
def _write_sessions(rows: list) -> None:
|
|
__SESSIONS_FILE.write_text(json.dumps(rows))
|
|
|
|
|
|
def _append_audit(event: str, username: str, details: dict) -> None:
|
|
line = json.dumps({"ts": time.time(), "event": event, "user": username, **details}) + "\n"
|
|
with _AUDIT_FILE.open("a") as _f:
|
|
_f.write(line)
|
|
|
|
|
|
def _session_validator(jti: str) -> bool:
|
|
return any(s.get("id") == jti for s in _read_sessions())
|
|
|
|
|
|
def _on_session_event(event: str, username: str, details: dict) -> None:
|
|
_append_audit(event, username, details)
|
|
if event == "login_success":
|
|
rows = _read_sessions()
|
|
rows.append({
|
|
"id": details.get("jti", secrets.token_hex(8)),
|
|
"username": username,
|
|
"ip": details.get("ip", ""),
|
|
"user_agent": details.get("user_agent", ""),
|
|
"created": datetime.utcnow().isoformat(),
|
|
"expires": int(time.time()) + details.get("expires_in", 86400),
|
|
"type": "jwt",
|
|
})
|
|
_write_sessions(rows)
|
|
|
|
|
|
def _revoke_sessions(username: str) -> int:
|
|
rows = _read_sessions()
|
|
keep = [r for r in rows if r.get("username") != username]
|
|
n = len(rows) - len(keep)
|
|
_write_sessions(keep)
|
|
_append_audit("sessions_revoked", username, {"count": n})
|
|
return n
|
|
|
|
|
|
set_session_validator(_session_validator)
|
|
# Module-load registration so secubox_core.auth fires our handler from the first request.
|
|
set_session_callback(_on_session_event)
|
|
_users_engine.set_revoke_callback(_revoke_sessions)
|
|
_users_engine.set_audit_callback(lambda evt, user, d: _append_audit(evt, user, d))
|
|
|
|
|
|
# ─── Branching login router ────────────────────────────────────────────
|
|
from fastapi import APIRouter as _APIRouter, Request as _Request
|
|
|
|
_login_router = _APIRouter(tags=["auth-v2"])
|
|
|
|
|
|
class _LoginIn(BaseModel):
|
|
username: str
|
|
password: str
|
|
|
|
|
|
class _MfaIn(BaseModel):
|
|
code: str
|
|
|
|
|
|
class _SetPasswordIn(BaseModel):
|
|
new_password: str
|
|
old_password: Optional[str] = None
|
|
|
|
|
|
def _check_scope(authorization: Optional[str], expected_scope: str) -> dict:
|
|
if not authorization or not authorization.lower().startswith("bearer "):
|
|
raise HTTPException(status_code=401, detail="Token Bearer manquant")
|
|
payload = _jwt_decode(authorization.split(" ", 1)[1])
|
|
if payload.get("scope") != expected_scope:
|
|
raise HTTPException(status_code=403, detail="Token hors scope")
|
|
return payload
|
|
|
|
|
|
@_login_router.post("/login")
|
|
async def _login_v2(req: _LoginIn, request: _Request):
|
|
"""Branching login: setup_token / mfa_token / enrollment_token / access_token."""
|
|
ip = (request.headers.get("X-Forwarded-For", "").split(",")[0].strip()
|
|
or request.headers.get("X-Real-IP", "")
|
|
or (request.client.host if request.client else ""))
|
|
ua = request.headers.get("User-Agent", "")[:100]
|
|
user = user_store.get_user(req.username)
|
|
|
|
if not user or not user.get("enabled"):
|
|
_emit_session_event("login_failed", req.username, {
|
|
"reason": "unknown_user" if not user else "disabled",
|
|
"ip": ip, "user_agent": ua,
|
|
})
|
|
raise HTTPException(status_code=401, detail="Identifiants incorrects")
|
|
|
|
# Initial-setup branch (empty password + must_change_password flag)
|
|
if user.get("must_change_password") and req.password == "":
|
|
setup_tok = create_token(req.username, scope="set-password", expires_in=900)
|
|
_append_audit("setup_token_issued", req.username, {"ip": ip})
|
|
return {"setup_required": True, "setup_token": setup_tok}
|
|
|
|
if not user.get("password_hash"):
|
|
_emit_session_event("login_failed", req.username, {"reason": "no_local_password", "ip": ip})
|
|
raise HTTPException(status_code=401, detail="Aucun mot de passe local")
|
|
|
|
if not user_store.verify_password(req.username, req.password):
|
|
_emit_session_event("login_failed", req.username, {"reason": "invalid_credentials", "ip": ip})
|
|
raise HTTPException(status_code=401, detail="Identifiants incorrects")
|
|
|
|
totp_block = user.get("totp") or {}
|
|
if totp_block.get("enabled"):
|
|
mfa_tok = create_token(req.username, scope="mfa-challenge", expires_in=300)
|
|
_append_audit("mfa_challenge_issued", req.username, {"ip": ip})
|
|
return {"mfa_required": True, "mfa_token": mfa_tok}
|
|
|
|
# Admin without TOTP → force enrollment
|
|
if user.get("role") == "admin":
|
|
enroll_tok = create_token(req.username, scope="totp-enroll", expires_in=900)
|
|
_append_audit("totp_enrollment_required", req.username, {"ip": ip})
|
|
return {"enrollment_required": True, "enrollment_token": enroll_tok}
|
|
|
|
jti = secrets.token_hex(8)
|
|
tok = create_token(req.username, jti=jti)
|
|
_on_session_event("login_success", req.username, {
|
|
"jti": jti, "expires_in": 86400, "ip": ip, "user_agent": ua,
|
|
})
|
|
_users_engine.touch_last_login(req.username)
|
|
return {"access_token": tok, "token_type": "bearer", "expires_in": 86400}
|
|
|
|
|
|
@_login_router.post("/login/mfa")
|
|
async def _login_mfa(req: _MfaIn, request: _Request):
|
|
payload = _check_scope(request.headers.get("Authorization"), "mfa-challenge")
|
|
username = payload["sub"]
|
|
if not user_store.is_enabled(username):
|
|
raise HTTPException(status_code=401, detail="Compte désactivé")
|
|
ok = (_users_engine.verify_totp_for_user(username, req.code)
|
|
or _users_engine.consume_backup_code(username, req.code))
|
|
if not ok:
|
|
_append_audit("mfa_failed", username, {})
|
|
raise HTTPException(status_code=401, detail="Code invalide")
|
|
jti = secrets.token_hex(8)
|
|
tok = create_token(username, jti=jti)
|
|
_on_session_event("login_success", username, {"jti": jti, "expires_in": 86400, "ip": ""})
|
|
_users_engine.touch_last_login(username)
|
|
return {"access_token": tok, "token_type": "bearer", "expires_in": 86400}
|
|
|
|
|
|
@_login_router.post("/totp/enroll")
|
|
async def _totp_enroll(request: _Request):
|
|
payload = _check_scope(request.headers.get("Authorization"), "totp-enroll")
|
|
username = payload["sub"]
|
|
existing = user_store.get_user(username) or {}
|
|
if (existing.get("totp") or {}).get("enabled"):
|
|
raise HTTPException(status_code=409, detail="Déjà enrôlé")
|
|
secret = _users_totp_mod.generate_secret()
|
|
_pending.put(payload["jti"], secret)
|
|
import socket as _socket
|
|
issuer = f"SecuBox · {_socket.gethostname()}"
|
|
uri = _users_totp_mod.provisioning_uri(username, secret, issuer=issuer)
|
|
return {"secret": secret, "otpauth_uri": uri, "qr_png_b64": ""}
|
|
|
|
|
|
@_login_router.post("/totp/confirm")
|
|
async def _totp_confirm(req: _MfaIn, request: _Request):
|
|
payload = _check_scope(request.headers.get("Authorization"), "totp-enroll")
|
|
username = payload["sub"]
|
|
secret = _pending.get(payload["jti"])
|
|
if not secret:
|
|
raise HTTPException(status_code=410, detail="Enrôlement expiré")
|
|
import pyotp as _pyotp
|
|
if not _pyotp.TOTP(secret).verify(req.code, valid_window=1):
|
|
raise HTTPException(status_code=401, detail="Code invalide")
|
|
backup_plain = _users_engine.enroll_totp(username, secret)
|
|
_pending.delete(payload["jti"])
|
|
jti = secrets.token_hex(8)
|
|
tok = create_token(username, jti=jti)
|
|
_on_session_event("login_success", username, {"jti": jti, "expires_in": 86400, "ip": ""})
|
|
return {
|
|
"access_token": tok, "token_type": "bearer", "expires_in": 86400,
|
|
"backup_codes": backup_plain,
|
|
"backup_codes_note": "Conservez ces codes. Affichés une seule fois.",
|
|
}
|
|
|
|
|
|
@_login_router.post("/set-password")
|
|
async def _set_password(req: _SetPasswordIn, request: _Request):
|
|
auth_h = request.headers.get("Authorization", "")
|
|
if not auth_h.lower().startswith("bearer "):
|
|
raise HTTPException(status_code=401, detail="Token Bearer manquant")
|
|
payload = _jwt_decode(auth_h.split(" ", 1)[1])
|
|
scope = payload.get("scope")
|
|
username = payload["sub"]
|
|
if scope == "set-password":
|
|
_users_engine.set_password(username, req.new_password)
|
|
_users_engine.revoke_sessions(username)
|
|
return {"ok": True, "message": "Mot de passe défini, veuillez vous reconnecter"}
|
|
if scope is None:
|
|
# Change-my-password with full JWT
|
|
if not req.old_password:
|
|
raise HTTPException(status_code=400, detail="Ancien mot de passe requis")
|
|
if not user_store.verify_password(username, req.old_password):
|
|
raise HTTPException(status_code=401, detail="Ancien mot de passe incorrect")
|
|
_users_engine.set_password(username, req.new_password)
|
|
return {"ok": True, "message": "Mot de passe modifié"}
|
|
raise HTTPException(status_code=403, detail="Token hors scope")
|
|
|
|
|
|
# Mount v2 router FIRST so its /login overrides the legacy auth_router /login.
|
|
# FastAPI uses the first matching route, so _login_router must come before auth_router.
|
|
app.include_router(_login_router, prefix="/auth")
|
|
app.include_router(auth_router, prefix="/auth")
|
|
router = APIRouter()
|
|
|
|
# Configuration — all paths are env-overridable via _DATA_DIR (SECUBOX_AUTH_DATA_DIR)
|
|
VOUCHERS_FILE = _DATA_DIR / "vouchers.json"
|
|
HISTORY_FILE = _DATA_DIR / "history.json"
|
|
WEBHOOKS_FILE = _DATA_DIR / "webhooks.json"
|
|
STATS_FILE = _DATA_DIR / "stats.json"
|
|
|
|
|
|
class StatsCache:
|
|
"""Thread-safe stats cache with TTL."""
|
|
|
|
def __init__(self, ttl_seconds: int = 30):
|
|
self.ttl = ttl_seconds
|
|
self._cache: Dict[str, Any] = {}
|
|
self._timestamps: Dict[str, float] = {}
|
|
self._lock = threading.Lock()
|
|
|
|
def get(self, key: str) -> Optional[Any]:
|
|
with self._lock:
|
|
if key in self._cache:
|
|
if time.time() - self._timestamps[key] < self.ttl:
|
|
return self._cache[key]
|
|
return None
|
|
|
|
def set(self, key: str, value: Any):
|
|
with self._lock:
|
|
self._cache[key] = value
|
|
self._timestamps[key] = time.time()
|
|
|
|
def clear(self):
|
|
with self._lock:
|
|
self._cache.clear()
|
|
self._timestamps.clear()
|
|
|
|
|
|
stats_cache = StatsCache(ttl_seconds=30)
|
|
|
|
|
|
# Pydantic Models
|
|
class VoucherRequest(BaseModel):
|
|
count: int = Field(default=1, ge=1, le=100)
|
|
duration_hours: int = Field(default=24, ge=1, le=8760)
|
|
bandwidth_mb: int = Field(default=0, ge=0)
|
|
prefix: str = Field(default="SBX", max_length=10)
|
|
|
|
|
|
class ProviderRequest(BaseModel):
|
|
provider_id: str
|
|
client_id: str
|
|
client_secret: str
|
|
enabled: bool = True
|
|
|
|
|
|
class WebhookConfig(BaseModel):
|
|
url: str
|
|
events: List[str] = Field(default=["login", "logout", "voucher_redeemed", "session_revoked"])
|
|
secret: Optional[str] = None
|
|
enabled: bool = True
|
|
|
|
@field_validator("url")
|
|
@classmethod
|
|
def validate_url(cls, v: str) -> str:
|
|
if not v.startswith(("http://", "https://")):
|
|
raise ValueError("URL must start with http:// or https://")
|
|
return v
|
|
|
|
|
|
# State
|
|
_cleanup_task: Optional[asyncio.Task] = None
|
|
|
|
|
|
def _load(p: Path, default=None):
|
|
"""Load JSON file safely."""
|
|
if p.exists():
|
|
try:
|
|
return json.loads(p.read_text())
|
|
except Exception:
|
|
pass
|
|
return default if default is not None else []
|
|
|
|
|
|
def _save(p: Path, data):
|
|
"""Save JSON file safely."""
|
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
p.write_text(json.dumps(data, indent=2))
|
|
|
|
|
|
def _load_history() -> List[Dict[str, Any]]:
|
|
return _load(HISTORY_FILE, [])
|
|
|
|
|
|
def _save_history(history: List[Dict[str, Any]]):
|
|
history = history[-2000:]
|
|
_save(HISTORY_FILE, history)
|
|
|
|
|
|
def _load_webhooks() -> List[Dict[str, Any]]:
|
|
return _load(WEBHOOKS_FILE, [])
|
|
|
|
|
|
def _save_webhooks(webhooks: List[Dict[str, Any]]):
|
|
_save(WEBHOOKS_FILE, webhooks)
|
|
|
|
|
|
def _record_event(event: str, details: Optional[Dict] = None):
|
|
"""Record an event in history."""
|
|
history = _load_history()
|
|
entry = {
|
|
"timestamp": datetime.now().isoformat(),
|
|
"event": event,
|
|
"details": details or {}
|
|
}
|
|
history.append(entry)
|
|
_save_history(history)
|
|
|
|
|
|
async def _send_webhook(url: str, payload: Dict[str, Any], secret: Optional[str] = None):
|
|
"""Send webhook notification."""
|
|
try:
|
|
headers = {"Content-Type": "application/json"}
|
|
body = json.dumps(payload)
|
|
|
|
if secret:
|
|
signature = hmac.new(
|
|
secret.encode(),
|
|
body.encode(),
|
|
hashlib.sha256
|
|
).hexdigest()
|
|
headers["X-SecuBox-Signature"] = f"sha256={signature}"
|
|
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
await client.post(url, content=body, headers=headers)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
async def _notify_webhooks(event: str, data: Dict[str, Any]):
|
|
"""Send notifications to all webhooks for event."""
|
|
webhooks = _load_webhooks()
|
|
for webhook in webhooks:
|
|
if webhook.get("enabled", True) and event in webhook.get("events", []):
|
|
await _send_webhook(
|
|
webhook["url"],
|
|
{"event": event, "data": data, "timestamp": datetime.now().isoformat()},
|
|
webhook.get("secret")
|
|
)
|
|
|
|
|
|
def _cleanup_expired():
|
|
"""Remove expired sessions and update stats."""
|
|
now = int(time.time())
|
|
sessions = _load(_SESSIONS_FILE, [])
|
|
active = [s for s in sessions if s.get("expires", 0) > now]
|
|
expired_count = len(sessions) - len(active)
|
|
|
|
if expired_count > 0:
|
|
_save(_SESSIONS_FILE, active)
|
|
_record_event("sessions_expired", {"count": expired_count})
|
|
|
|
return expired_count
|
|
|
|
|
|
async def _periodic_cleanup():
|
|
"""Background task to clean up expired sessions."""
|
|
while True:
|
|
try:
|
|
_cleanup_expired()
|
|
except Exception:
|
|
pass
|
|
await asyncio.sleep(300) # Every 5 minutes
|
|
|
|
|
|
|
|
@app.on_event("startup")
|
|
async def startup():
|
|
"""Start background cleanup task."""
|
|
global _cleanup_task
|
|
_cleanup_task = asyncio.create_task(_periodic_cleanup())
|
|
|
|
|
|
@app.on_event("shutdown")
|
|
async def shutdown():
|
|
"""Stop background cleanup."""
|
|
global _cleanup_task
|
|
if _cleanup_task:
|
|
_cleanup_task.cancel()
|
|
|
|
|
|
# Public endpoints
|
|
@router.get("/health")
|
|
async def health():
|
|
return {"status": "ok", "module": "auth", "version": "2.0.0"}
|
|
|
|
|
|
@router.get("/status")
|
|
async def status(user=Depends(require_jwt)):
|
|
"""Get auth module status."""
|
|
cached = stats_cache.get("status")
|
|
if cached:
|
|
return cached
|
|
|
|
cfg = get_config("oauth")
|
|
now = int(time.time())
|
|
sessions = _load(_SESSIONS_FILE, [])
|
|
active_sessions = [s for s in sessions if s.get("expires", 0) > now]
|
|
vouchers = _load(VOUCHERS_FILE, [])
|
|
|
|
result = {
|
|
"sessions": {
|
|
"active": len(active_sessions),
|
|
"total": len(sessions)
|
|
},
|
|
"vouchers": {
|
|
"total": len(vouchers),
|
|
"unused": sum(1 for v in vouchers if not v.get("used")),
|
|
"used": sum(1 for v in vouchers if v.get("used"))
|
|
},
|
|
"oauth_providers": list(cfg.keys()) if cfg else [],
|
|
"timestamp": datetime.now().isoformat()
|
|
}
|
|
|
|
stats_cache.set("status", result)
|
|
return result
|
|
|
|
|
|
@router.get("/sessions")
|
|
async def sessions(user=Depends(require_jwt)):
|
|
"""Get active sessions."""
|
|
now = int(time.time())
|
|
all_sessions = _load(_SESSIONS_FILE, [])
|
|
active = []
|
|
|
|
for s in all_sessions:
|
|
if s.get("expires", 0) > now:
|
|
session = s.copy()
|
|
session["remaining_seconds"] = s.get("expires", 0) - now
|
|
session["remaining_human"] = str(timedelta(seconds=session["remaining_seconds"]))
|
|
active.append(session)
|
|
|
|
return {
|
|
"sessions": active,
|
|
"count": len(active)
|
|
}
|
|
|
|
|
|
@router.get("/sessions/stats")
|
|
async def session_stats(user=Depends(require_jwt)):
|
|
"""Get session statistics."""
|
|
now = int(time.time())
|
|
sessions = _load(_SESSIONS_FILE, [])
|
|
active = [s for s in sessions if s.get("expires", 0) > now]
|
|
|
|
# Group by type
|
|
by_type: Dict[str, int] = {}
|
|
for s in active:
|
|
stype = s.get("type", "unknown")
|
|
by_type[stype] = by_type.get(stype, 0) + 1
|
|
|
|
return {
|
|
"total": len(sessions),
|
|
"active": len(active),
|
|
"expired": len(sessions) - len(active),
|
|
"by_type": by_type,
|
|
"timestamp": datetime.now().isoformat()
|
|
}
|
|
|
|
|
|
@router.get("/vouchers")
|
|
async def vouchers(user=Depends(require_jwt)):
|
|
"""Get all vouchers."""
|
|
all_vouchers = _load(VOUCHERS_FILE, [])
|
|
return {
|
|
"vouchers": all_vouchers,
|
|
"total": len(all_vouchers),
|
|
"unused": sum(1 for v in all_vouchers if not v.get("used")),
|
|
"used": sum(1 for v in all_vouchers if v.get("used"))
|
|
}
|
|
|
|
|
|
@router.get("/vouchers/stats")
|
|
async def voucher_stats(user=Depends(require_jwt)):
|
|
"""Get voucher statistics."""
|
|
vouchers_list = _load(VOUCHERS_FILE, [])
|
|
now = int(time.time())
|
|
|
|
# Recent usage (last 24h)
|
|
cutoff = now - 86400
|
|
recent_used = sum(
|
|
1 for v in vouchers_list
|
|
if v.get("used") and v.get("used_at", 0) > cutoff
|
|
)
|
|
|
|
# By prefix
|
|
by_prefix: Dict[str, Dict[str, int]] = {}
|
|
for v in vouchers_list:
|
|
code = v.get("code", "")
|
|
prefix = code.split("-")[0] if "-" in code else "UNKNOWN"
|
|
if prefix not in by_prefix:
|
|
by_prefix[prefix] = {"total": 0, "used": 0}
|
|
by_prefix[prefix]["total"] += 1
|
|
if v.get("used"):
|
|
by_prefix[prefix]["used"] += 1
|
|
|
|
return {
|
|
"total": len(vouchers_list),
|
|
"unused": sum(1 for v in vouchers_list if not v.get("used")),
|
|
"used": sum(1 for v in vouchers_list if v.get("used")),
|
|
"used_last_24h": recent_used,
|
|
"by_prefix": by_prefix,
|
|
"timestamp": datetime.now().isoformat()
|
|
}
|
|
|
|
|
|
@router.get("/oauth_providers")
|
|
async def oauth_providers(user=Depends(require_jwt)):
|
|
"""List OAuth providers."""
|
|
cfg = get_config("oauth")
|
|
providers = []
|
|
for k, v in (cfg or {}).items():
|
|
providers.append({
|
|
"id": k,
|
|
"configured": bool(v.get("client_id")),
|
|
"enabled": v.get("enabled", True)
|
|
})
|
|
return {"providers": providers}
|
|
|
|
|
|
@router.get("/splash_config")
|
|
async def splash_config(user=Depends(require_jwt)):
|
|
"""Get captive portal splash config."""
|
|
cfg = get_config("auth") or {}
|
|
return {
|
|
"title": cfg.get("splash_title", "SecuBox Guest Access"),
|
|
"logo": cfg.get("splash_logo", "/logo.png"),
|
|
"methods": cfg.get("splash_methods", ["voucher", "oauth"]),
|
|
"welcome_message": cfg.get("welcome_message", "Welcome to SecuBox Network")
|
|
}
|
|
|
|
|
|
@router.get("/bypass_rules")
|
|
async def bypass_rules(user=Depends(require_jwt)):
|
|
"""Get auth bypass rules."""
|
|
rules_file = Path("/etc/secubox/auth-bypass.json")
|
|
return {"rules": _load(rules_file, [])}
|
|
|
|
|
|
@router.post("/generate_vouchers")
|
|
async def generate_vouchers(req: VoucherRequest, user=Depends(require_jwt)):
|
|
"""Generate new vouchers."""
|
|
vouchers_list = _load(VOUCHERS_FILE, [])
|
|
new_vouchers = []
|
|
|
|
for _ in range(req.count):
|
|
code = f"{req.prefix}-{secrets.token_hex(4).upper()}"
|
|
v = {
|
|
"code": code,
|
|
"duration_hours": req.duration_hours,
|
|
"bandwidth_mb": req.bandwidth_mb,
|
|
"created": int(time.time()),
|
|
"created_at": datetime.now().isoformat(),
|
|
"used": False,
|
|
"created_by": user.get("sub", "unknown")
|
|
}
|
|
vouchers_list.append(v)
|
|
new_vouchers.append(v)
|
|
|
|
_save(VOUCHERS_FILE, vouchers_list)
|
|
_record_event("vouchers_generated", {
|
|
"count": len(new_vouchers),
|
|
"prefix": req.prefix,
|
|
"by": user.get("sub")
|
|
})
|
|
stats_cache.clear()
|
|
|
|
return {
|
|
"created": len(new_vouchers),
|
|
"vouchers": new_vouchers
|
|
}
|
|
|
|
|
|
@router.post("/redeem_voucher")
|
|
async def redeem_voucher(code: str, client_ip: Optional[str] = None):
|
|
"""Redeem a voucher (public endpoint)."""
|
|
vouchers_list = _load(VOUCHERS_FILE, [])
|
|
|
|
for v in vouchers_list:
|
|
if v["code"] == code and not v.get("used"):
|
|
v["used"] = True
|
|
v["used_at"] = int(time.time())
|
|
v["used_at_iso"] = datetime.now().isoformat()
|
|
v["client_ip"] = client_ip
|
|
|
|
_save(VOUCHERS_FILE, vouchers_list)
|
|
|
|
# Create session
|
|
sessions = _load(_SESSIONS_FILE, [])
|
|
session = {
|
|
"id": secrets.token_hex(8),
|
|
"type": "voucher",
|
|
"voucher_code": code,
|
|
"created": int(time.time()),
|
|
"expires": int(time.time()) + v["duration_hours"] * 3600,
|
|
"client_ip": client_ip,
|
|
"bandwidth_mb": v.get("bandwidth_mb", 0)
|
|
}
|
|
sessions.append(session)
|
|
_save(_SESSIONS_FILE, sessions)
|
|
|
|
token = create_token(f"voucher:{code}", expires_in=v["duration_hours"] * 3600)
|
|
|
|
_record_event("voucher_redeemed", {
|
|
"code": code,
|
|
"client_ip": client_ip,
|
|
"duration_hours": v["duration_hours"]
|
|
})
|
|
await _notify_webhooks("voucher_redeemed", {
|
|
"code": code,
|
|
"client_ip": client_ip
|
|
})
|
|
stats_cache.clear()
|
|
|
|
return {
|
|
"success": True,
|
|
"token": token,
|
|
"session_id": session["id"],
|
|
"expires_in": v["duration_hours"] * 3600
|
|
}
|
|
|
|
raise HTTPException(400, "Voucher invalide ou déjà utilisé")
|
|
|
|
|
|
@router.get("/validate_voucher")
|
|
async def validate_voucher(code: str, user=Depends(require_jwt)):
|
|
"""Validate a voucher without consuming it."""
|
|
vouchers_list = _load(VOUCHERS_FILE, [])
|
|
for v in vouchers_list:
|
|
if v["code"] == code:
|
|
return {
|
|
"valid": not v.get("used", False),
|
|
"voucher": v
|
|
}
|
|
return {"valid": False, "error": "Voucher not found"}
|
|
|
|
|
|
@router.post("/delete_voucher")
|
|
async def delete_voucher(code: str, user=Depends(require_jwt)):
|
|
"""Delete a voucher."""
|
|
vouchers_list = _load(VOUCHERS_FILE, [])
|
|
original_count = len(vouchers_list)
|
|
vouchers_list = [v for v in vouchers_list if v.get("code") != code]
|
|
|
|
if len(vouchers_list) < original_count:
|
|
_save(VOUCHERS_FILE, vouchers_list)
|
|
_record_event("voucher_deleted", {"code": code, "by": user.get("sub")})
|
|
stats_cache.clear()
|
|
return {"success": True, "deleted": code}
|
|
|
|
return {"success": False, "error": "Voucher not found"}
|
|
|
|
|
|
@router.post("/revoke_session")
|
|
async def revoke_session(session_id: str, user=Depends(require_jwt)):
|
|
"""Revoke a session."""
|
|
sessions = _load(_SESSIONS_FILE, [])
|
|
original_count = len(sessions)
|
|
revoked_session = next((s for s in sessions if s.get("id") == session_id), None)
|
|
sessions = [s for s in sessions if s.get("id") != session_id]
|
|
|
|
if len(sessions) < original_count:
|
|
_save(_SESSIONS_FILE, sessions)
|
|
_record_event("session_revoked", {
|
|
"session_id": session_id,
|
|
"by": user.get("sub"),
|
|
"session_type": revoked_session.get("type") if revoked_session else "unknown"
|
|
})
|
|
await _notify_webhooks("session_revoked", {"session_id": session_id})
|
|
stats_cache.clear()
|
|
return {"success": True, "revoked": session_id}
|
|
|
|
return {"success": False, "error": "Session not found"}
|
|
|
|
|
|
@router.post("/set_provider")
|
|
async def set_provider(req: ProviderRequest, user=Depends(require_jwt)):
|
|
"""Configure an OAuth provider."""
|
|
_record_event("provider_configured", {
|
|
"provider_id": req.provider_id,
|
|
"by": user.get("sub")
|
|
})
|
|
return {"success": True, "provider": req.provider_id}
|
|
|
|
|
|
@router.post("/delete_provider")
|
|
async def delete_provider(provider_id: str, user=Depends(require_jwt)):
|
|
"""Delete an OAuth provider."""
|
|
_record_event("provider_deleted", {
|
|
"provider_id": provider_id,
|
|
"by": user.get("sub")
|
|
})
|
|
return {"success": True, "deleted": provider_id}
|
|
|
|
|
|
@router.get("/history")
|
|
async def get_history(limit: int = 100, event: Optional[str] = None, user=Depends(require_jwt)):
|
|
"""Get event history."""
|
|
history = _load_history()
|
|
|
|
if event:
|
|
history = [h for h in history if h.get("event") == event]
|
|
|
|
return {
|
|
"events": history[-limit:],
|
|
"total": len(history)
|
|
}
|
|
|
|
|
|
@router.get("/logs")
|
|
async def get_logs(lines: int = 100, user=Depends(require_jwt)):
|
|
"""Get auth service logs."""
|
|
try:
|
|
r = subprocess.run(
|
|
["journalctl", "-u", "secubox-auth", "-n", str(min(lines, 500)), "--no-pager"],
|
|
capture_output=True, text=True, timeout=10
|
|
)
|
|
return {"lines": r.stdout.splitlines(), "count": len(r.stdout.splitlines())}
|
|
except Exception as e:
|
|
return {"lines": [], "error": str(e)}
|
|
|
|
|
|
@router.get("/webhooks")
|
|
async def list_webhooks(user=Depends(require_jwt)):
|
|
"""List configured webhooks."""
|
|
return {"webhooks": _load_webhooks()}
|
|
|
|
|
|
@router.post("/webhooks")
|
|
async def add_webhook(webhook: WebhookConfig, user=Depends(require_jwt)):
|
|
"""Add a new webhook."""
|
|
webhooks = _load_webhooks()
|
|
webhook_data = webhook.model_dump()
|
|
webhook_data["id"] = hashlib.md5(webhook.url.encode()).hexdigest()[:8]
|
|
webhook_data["created_at"] = datetime.now().isoformat()
|
|
webhooks.append(webhook_data)
|
|
_save_webhooks(webhooks)
|
|
return {"success": True, "webhook": webhook_data}
|
|
|
|
|
|
@router.delete("/webhooks/{webhook_id}")
|
|
async def delete_webhook(webhook_id: str, user=Depends(require_jwt)):
|
|
"""Delete a webhook."""
|
|
webhooks = _load_webhooks()
|
|
webhooks = [w for w in webhooks if w.get("id") != webhook_id]
|
|
_save_webhooks(webhooks)
|
|
return {"success": True}
|
|
|
|
|
|
@router.get("/summary")
|
|
async def summary(user=Depends(require_jwt)):
|
|
"""Get auth module summary."""
|
|
now = int(time.time())
|
|
sessions = _load(_SESSIONS_FILE, [])
|
|
vouchers_list = _load(VOUCHERS_FILE, [])
|
|
cfg = get_config("oauth")
|
|
|
|
active_sessions = [s for s in sessions if s.get("expires", 0) > now]
|
|
|
|
# Recent activity (last 24h)
|
|
history = _load_history()
|
|
cutoff = datetime.now() - timedelta(hours=24)
|
|
recent = [
|
|
h for h in history
|
|
if datetime.fromisoformat(h.get("timestamp", "2000-01-01")) > cutoff
|
|
]
|
|
|
|
return {
|
|
"sessions": {
|
|
"active": len(active_sessions),
|
|
"total": len(sessions)
|
|
},
|
|
"vouchers": {
|
|
"total": len(vouchers_list),
|
|
"unused": sum(1 for v in vouchers_list if not v.get("used")),
|
|
"used": sum(1 for v in vouchers_list if v.get("used"))
|
|
},
|
|
"oauth_providers": len(cfg) if cfg else 0,
|
|
"activity_24h": len(recent),
|
|
"recent_events": history[-5:],
|
|
"webhooks_configured": len(_load_webhooks()),
|
|
"timestamp": datetime.now().isoformat()
|
|
}
|
|
|
|
|
|
# Aliases for compatibility
|
|
@router.get("/list_providers")
|
|
async def list_providers(user=Depends(require_jwt)):
|
|
return await oauth_providers(user)
|
|
|
|
|
|
@router.get("/list_vouchers")
|
|
async def list_vouchers(user=Depends(require_jwt)):
|
|
return await vouchers(user)
|
|
|
|
|
|
@router.post("/create_voucher")
|
|
async def create_voucher(
|
|
duration_hours: int = 24,
|
|
bandwidth_mb: int = 0,
|
|
prefix: str = "SBX",
|
|
user=Depends(require_jwt)
|
|
):
|
|
req = VoucherRequest(count=1, duration_hours=duration_hours, bandwidth_mb=bandwidth_mb, prefix=prefix)
|
|
result = await generate_vouchers(req, user)
|
|
return result["vouchers"][0] if result["vouchers"] else {}
|
|
|
|
|
|
@router.get("/list_sessions")
|
|
async def list_sessions(user=Depends(require_jwt)):
|
|
return await sessions(user)
|
|
|
|
|
|
app.include_router(router)
|