refactor: decompose monolith — app.py 10,600→143 lines, 30+ modules
Fase 0 complete: extract all business logic, auth, database, and 176 endpoints from monolithic app.py into dedicated modules. Structure: - app.py: FastAPI hub (CORS, routers, startup/shutdown) - models.py: 13 Pydantic request models - utils.py: shared utilities (embed status, upload validation, process registries) - config.py: constants, env vars, model catalogs - auth/: crypto, jwt_auth, oidc, rate_limit - database/: db(), init_db(), schema DDL - services/: genai, compliance, chat, terraform, embeddings, cis_reports, mcp - routes/: 13 APIRouter modules (auth, users, oci_config, oci_explorer, genai, mcp, adb, embeddings, reports, chat, terraform, settings, cis_engine) Also: README updated with OCIR auth instructions for manual docker run. 86 tests passing.
This commit is contained in:
0
backend/auth/__init__.py
Normal file
0
backend/auth/__init__.py
Normal file
91
backend/auth/crypto.py
Normal file
91
backend/auth/crypto.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
Cryptographic helpers — password hashing, Fernet encryption, TOTP, JWT tokens.
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
import struct
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import jwt as pyjwt
|
||||
from cryptography.fernet import Fernet as _Fernet
|
||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC as _PBKDF2HMAC
|
||||
from cryptography.hazmat.primitives import hashes as _hashes
|
||||
|
||||
from config import APP_SECRET, JWT_ALG, JWT_EXP_H
|
||||
|
||||
# ── Password Hashing (PBKDF2-SHA256, 100k iterations) ───────────────────────
|
||||
def _hash_pw(pw):
|
||||
salt = secrets.token_hex(16)
|
||||
h = hashlib.pbkdf2_hmac("sha256", pw.encode(), salt.encode(), 100_000)
|
||||
return f"{salt}:{h.hex()}"
|
||||
|
||||
def _verify_pw(pw, stored):
|
||||
salt, h = stored.split(":")
|
||||
return hmac.compare_digest(
|
||||
hashlib.pbkdf2_hmac("sha256", pw.encode(), salt.encode(), 100_000).hex(), h
|
||||
)
|
||||
|
||||
# ── TOTP (RFC 6238, Google Authenticator compatible) ─────────────────────────
|
||||
def _totp_secret():
|
||||
return base64.b32encode(secrets.token_bytes(20)).decode()
|
||||
|
||||
def _totp_verify(secret, code, window=1):
|
||||
key = base64.b32decode(secret)
|
||||
now = int(time.time()) // 30
|
||||
for off in range(-window, window + 1):
|
||||
msg = struct.pack(">Q", now + off)
|
||||
h = hmac.new(key, msg, hashlib.sha1).digest()
|
||||
o = h[-1] & 0x0F
|
||||
c = str((struct.unpack(">I", h[o:o+4])[0] & 0x7FFFFFFF) % 1_000_000).zfill(6)
|
||||
if hmac.compare_digest(c, code):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _totp_uri(secret, user):
|
||||
return f"otpauth://totp/AI-Agent:{user}?secret={secret}&issuer=AI-Agent"
|
||||
|
||||
# ── JWT Token ────────────────────────────────────────────────────────────────
|
||||
def _make_token(uid, role, sid):
|
||||
return pyjwt.encode(
|
||||
{"sub": uid, "role": role, "sid": sid,
|
||||
"exp": datetime.utcnow() + timedelta(hours=JWT_EXP_H),
|
||||
"iat": datetime.utcnow()},
|
||||
APP_SECRET, algorithm=JWT_ALG
|
||||
)
|
||||
|
||||
# ── Fernet Encryption (AES-128-CBC + HMAC-SHA256) ───────────────────────────
|
||||
_FERNET_PREFIX = "fernet:"
|
||||
|
||||
def _derive_fernet_key(secret: str) -> bytes:
|
||||
kdf = _PBKDF2HMAC(
|
||||
algorithm=_hashes.SHA256(), length=32,
|
||||
salt=b"oci-cis-agent-fernet-v1", iterations=100_000
|
||||
)
|
||||
return base64.urlsafe_b64encode(kdf.derive(secret.encode()))
|
||||
|
||||
_fernet = _Fernet(_derive_fernet_key(APP_SECRET))
|
||||
|
||||
def _enc(v):
|
||||
if not v: return v
|
||||
return _FERNET_PREFIX + _fernet.encrypt(v.encode()).decode()
|
||||
|
||||
def _dec(v):
|
||||
if not v: return v
|
||||
if v.startswith(_FERNET_PREFIX):
|
||||
return _fernet.decrypt(v[len(_FERNET_PREFIX):].encode()).decode()
|
||||
return base64.b64decode(v.encode()).decode()
|
||||
|
||||
def _mask(v, show=6):
|
||||
if not v or len(v) <= show:
|
||||
return "\u2022" * 8
|
||||
return "\u2022" * min(len(v) - show, 20) + v[-show:]
|
||||
|
||||
def _safe_dec(v):
|
||||
if not v: return v
|
||||
try:
|
||||
return _dec(v)
|
||||
except Exception:
|
||||
return v
|
||||
105
backend/auth/jwt_auth.py
Normal file
105
backend/auth/jwt_auth.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""JWT authentication — current_user dependency, require roles, audit/log helpers."""
|
||||
import uuid
|
||||
|
||||
import jwt as pyjwt
|
||||
from fastapi import HTTPException, Depends
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
|
||||
from config import APP_SECRET, JWT_ALG
|
||||
from database import db
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
async def current_user(cred: HTTPAuthorizationCredentials = Depends(security)):
|
||||
try:
|
||||
p = pyjwt.decode(cred.credentials, APP_SECRET, algorithms=[JWT_ALG])
|
||||
except pyjwt.ExpiredSignatureError:
|
||||
raise HTTPException(401, "Token expirado")
|
||||
except pyjwt.InvalidTokenError:
|
||||
raise HTTPException(401, "Token inválido")
|
||||
with db() as c:
|
||||
u = c.execute("SELECT * FROM users WHERE id=? AND is_active=1", (p["sub"],)).fetchone()
|
||||
s = c.execute("SELECT * FROM sessions WHERE id=? AND is_active=1", (p["sid"],)).fetchone()
|
||||
if not u or not s:
|
||||
raise HTTPException(401, "Sessão inválida")
|
||||
return dict(u)
|
||||
|
||||
|
||||
def _user_from_token(token: str):
|
||||
try:
|
||||
p = pyjwt.decode(token, APP_SECRET, algorithms=[JWT_ALG])
|
||||
except Exception:
|
||||
raise HTTPException(401, "Token inválido")
|
||||
with db() as c:
|
||||
u = c.execute("SELECT * FROM users WHERE id=? AND is_active=1", (p["sub"],)).fetchone()
|
||||
s = c.execute("SELECT * FROM sessions WHERE id=? AND is_active=1", (p["sid"],)).fetchone()
|
||||
if not u or not s:
|
||||
raise HTTPException(401, "Sessão inválida")
|
||||
return dict(u)
|
||||
|
||||
|
||||
def require(*roles):
|
||||
async def dep(u=Depends(current_user)):
|
||||
if u["role"] not in roles:
|
||||
raise HTTPException(403, f"Requer role: {', '.join(roles)}")
|
||||
return u
|
||||
return dep
|
||||
|
||||
|
||||
def _audit(uid, uname, action, resource=None, details=None, ip=None):
|
||||
with db() as c:
|
||||
c.execute(
|
||||
"INSERT INTO audit_log (id,user_id,username,action,resource,details,ip) VALUES (?,?,?,?,?,?,?)",
|
||||
(str(uuid.uuid4()), uid, uname, action, resource, details, ip))
|
||||
|
||||
|
||||
def _config_log(config_type, config_id, config_name, severity, action, message, uid=None, uname=None):
|
||||
with db() as c:
|
||||
c.execute(
|
||||
"INSERT INTO config_logs (id,config_type,config_id,config_name,severity,action,message,user_id,username) VALUES (?,?,?,?,?,?,?,?,?)",
|
||||
(str(uuid.uuid4()), config_type, config_id, config_name or "", severity, action, str(message)[:2000], uid, uname))
|
||||
|
||||
|
||||
def _chat_log(sid, mid, uid, severity, source, action, message):
|
||||
with db() as c:
|
||||
c.execute(
|
||||
"INSERT INTO chat_logs (id,session_id,message_id,user_id,severity,source,action,message) VALUES (?,?,?,?,?,?,?,?)",
|
||||
(str(uuid.uuid4()), sid, mid, uid, severity, source, action, str(message)[:2000]))
|
||||
|
||||
|
||||
# ── Access Control ───────────────────────────────────────────────────────────
|
||||
_CONFIG_TABLES = {
|
||||
"oci": "oci_configs",
|
||||
"genai": "genai_configs",
|
||||
"adb": "adb_vector_configs",
|
||||
"mcp": "mcp_servers",
|
||||
}
|
||||
|
||||
|
||||
def _verify_config_access(config_type: str, config_id: str, user: dict, *, require_owner: bool = False) -> dict:
|
||||
table = _CONFIG_TABLES.get(config_type)
|
||||
if not table:
|
||||
raise HTTPException(400, f"Tipo de config inválido: {config_type}")
|
||||
with db() as c:
|
||||
row = c.execute(f"SELECT * FROM {table} WHERE id=?", (config_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404)
|
||||
row = dict(row)
|
||||
if user["role"] == "admin":
|
||||
return row
|
||||
is_owner = row.get("user_id") == user["id"]
|
||||
is_global = row.get("is_global", 0)
|
||||
if not is_owner and not is_global:
|
||||
raise HTTPException(404)
|
||||
if require_owner and not is_owner:
|
||||
raise HTTPException(403, "Apenas o dono pode modificar este recurso")
|
||||
return row
|
||||
|
||||
|
||||
def _verify_report_access(report_id: str, user: dict) -> dict:
|
||||
with db() as c:
|
||||
row = c.execute("SELECT * FROM reports WHERE id=? AND user_id=?", (report_id, user["id"])).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404)
|
||||
return dict(row)
|
||||
93
backend/auth/oidc.py
Normal file
93
backend/auth/oidc.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""OIDC helpers — discovery, JWKS, state management, token validation."""
|
||||
import json
|
||||
import time
|
||||
import threading
|
||||
|
||||
import jwt as pyjwt
|
||||
from fastapi import HTTPException
|
||||
|
||||
from database import db
|
||||
from auth.crypto import _safe_dec
|
||||
|
||||
# ── Caches ───────────────────────────────────────────────────────────────────
|
||||
_oidc_discovery_cache: dict = {}
|
||||
_oidc_jwks_cache: dict = {}
|
||||
_OIDC_CACHE_TTL = 3600
|
||||
|
||||
_oidc_pending: dict = {}
|
||||
_oidc_pending_lock = threading.Lock()
|
||||
_OIDC_STATE_TTL = 300
|
||||
|
||||
|
||||
def _get_oidc_config() -> dict:
|
||||
keys = ("auth_mode", "oidc_issuer", "oidc_client_id", "oidc_client_secret",
|
||||
"oidc_redirect_uri", "oidc_group_admin", "oidc_group_user", "oidc_group_viewer")
|
||||
cfg = {}
|
||||
with db() as c:
|
||||
for k in keys:
|
||||
row = c.execute("SELECT value FROM app_settings WHERE key=?", (k,)).fetchone()
|
||||
cfg[k] = row["value"] if row else ""
|
||||
if cfg.get("oidc_client_secret"):
|
||||
cfg["oidc_client_secret"] = _safe_dec(cfg["oidc_client_secret"])
|
||||
return cfg
|
||||
|
||||
|
||||
def _oidc_discover(issuer: str) -> dict:
|
||||
import requests as req
|
||||
now = time.time()
|
||||
cached = _oidc_discovery_cache.get(issuer)
|
||||
if cached and now - cached[1] < _OIDC_CACHE_TTL:
|
||||
return cached[0]
|
||||
url = issuer.rstrip("/") + "/.well-known/openid-configuration"
|
||||
resp = req.get(url, timeout=10)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
_oidc_discovery_cache[issuer] = (data, now)
|
||||
return data
|
||||
|
||||
|
||||
def _oidc_get_jwks(jwks_uri: str) -> list:
|
||||
import requests as req
|
||||
now = time.time()
|
||||
cached = _oidc_jwks_cache.get(jwks_uri)
|
||||
if cached and now - cached[1] < _OIDC_CACHE_TTL:
|
||||
return cached[0]
|
||||
resp = req.get(jwks_uri, timeout=10)
|
||||
resp.raise_for_status()
|
||||
keys = resp.json().get("keys", [])
|
||||
_oidc_jwks_cache[jwks_uri] = (keys, now)
|
||||
return keys
|
||||
|
||||
|
||||
def _oidc_store_state(state: str, nonce: str):
|
||||
with _oidc_pending_lock:
|
||||
now = time.time()
|
||||
expired = [k for k, v in _oidc_pending.items() if now - v["created"] > _OIDC_STATE_TTL]
|
||||
for k in expired:
|
||||
del _oidc_pending[k]
|
||||
_oidc_pending[state] = {"nonce": nonce, "created": now}
|
||||
|
||||
|
||||
def _oidc_pop_state(state: str) -> dict | None:
|
||||
with _oidc_pending_lock:
|
||||
return _oidc_pending.pop(state, None)
|
||||
|
||||
|
||||
def _validate_id_token(id_token: str, issuer: str, client_id: str, nonce: str) -> dict:
|
||||
from jwt.algorithms import RSAAlgorithm
|
||||
disco = _oidc_discover(issuer)
|
||||
jwks = _oidc_get_jwks(disco["jwks_uri"])
|
||||
header = pyjwt.get_unverified_header(id_token)
|
||||
kid = header.get("kid")
|
||||
key_data = None
|
||||
for k in jwks:
|
||||
if k.get("kid") == kid:
|
||||
key_data = k
|
||||
break
|
||||
if not key_data:
|
||||
raise HTTPException(401, "OIDC: chave não encontrada no JWKS")
|
||||
public_key = RSAAlgorithm.from_jwk(json.dumps(key_data))
|
||||
claims = pyjwt.decode(id_token, public_key, algorithms=["RS256"], audience=client_id, issuer=issuer)
|
||||
if claims.get("nonce") != nonce:
|
||||
raise HTTPException(401, "OIDC: nonce inválido")
|
||||
return claims
|
||||
20
backend/auth/rate_limit.py
Normal file
20
backend/auth/rate_limit.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""Rate limiting for login brute-force protection."""
|
||||
import time
|
||||
import threading
|
||||
from fastapi import HTTPException
|
||||
|
||||
_login_attempts: dict = {}
|
||||
_login_attempts_lock = threading.Lock()
|
||||
_LOGIN_WINDOW = 300 # 5 minutes
|
||||
_LOGIN_MAX = 10 # max attempts per window
|
||||
|
||||
|
||||
def _check_rate_limit(ip: str):
|
||||
now = time.time()
|
||||
with _login_attempts_lock:
|
||||
attempts = _login_attempts.get(ip, [])
|
||||
attempts = [t for t in attempts if now - t < _LOGIN_WINDOW]
|
||||
if len(attempts) >= _LOGIN_MAX:
|
||||
raise HTTPException(429, "Muitas tentativas de login. Aguarde 5 minutos.")
|
||||
attempts.append(now)
|
||||
_login_attempts[ip] = attempts
|
||||
Reference in New Issue
Block a user