Files
A-Team-Security-Infra-Agent…/backend/routes/auth.py
nogueiraguh 1135e9d6a9 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.
2026-04-06 15:20:10 -03:00

228 lines
13 KiB
Python

"""Auth routes — login, logout, register, change-password, OIDC, MFA."""
import json
import re
import secrets
import uuid
from datetime import datetime, timedelta
from urllib.parse import urlencode
from fastapi import APIRouter, HTTPException, Depends, Request, Query
from fastapi.responses import HTMLResponse, RedirectResponse
from config import JWT_EXP_H
from database import db
from auth.crypto import _hash_pw, _verify_pw, _totp_secret, _totp_verify, _totp_uri, _make_token
from auth.oidc import _get_oidc_config, _oidc_discover, _oidc_store_state, _oidc_pop_state, _validate_id_token
from auth.jwt_auth import current_user, require, _audit
from auth.rate_limit import _check_rate_limit
from models import LoginReq, RegisterReq, TOTPVerify, ChangePwReq
router = APIRouter()
# ── Auth endpoints ────────────────────────────────────────────────────────────
@router.post("/api/auth/login")
async def login(req: LoginReq, request: Request):
_check_rate_limit(request.client.host if request.client else "unknown")
with db() as c:
mode_row = c.execute("SELECT value FROM app_settings WHERE key='auth_mode'").fetchone()
if mode_row and mode_row["value"] == "oidc":
raise HTTPException(400, "Login local desabilitado. Use Oracle IAM.")
with db() as c:
u = c.execute("SELECT * FROM users WHERE username=? AND is_active=1", (req.username,)).fetchone()
if not u or not _verify_pw(req.password, u["password_hash"]): raise HTTPException(401, "Credenciais inválidas")
u = dict(u)
if u["mfa_enabled"]:
if not req.totp_code: return {"mfa_required": True, "message": "Código MFA necessário"}
if not _totp_verify(u["mfa_secret"], req.totp_code): raise HTTPException(401, "Código MFA inválido")
sid = str(uuid.uuid4()); exp = (datetime.utcnow()+timedelta(hours=JWT_EXP_H)).isoformat()
with db() as c:
c.execute("INSERT INTO sessions (id,user_id,expires_at) VALUES (?,?,?)", (sid, u["id"], exp))
c.execute("UPDATE users SET last_login=datetime('now') WHERE id=?", (u["id"],))
_audit(u["id"], u["username"], "login", ip=request.client.host if request.client else None)
return {"token":_make_token(u["id"],u["role"],sid),
"user":{"id":u["id"],"first_name":u["first_name"],"last_name":u["last_name"],"username":u["username"],"email":u["email"],"role":u["role"],"mfa_enabled":bool(u["mfa_enabled"]),"timezone":u.get("timezone","America/Sao_Paulo")},
"must_change_password": bool(u.get("must_change_password", 0))}
@router.post("/api/auth/logout")
async def logout_ep(u=Depends(current_user)):
with db() as c: c.execute("UPDATE sessions SET is_active=0 WHERE user_id=?", (u["id"],))
return {"ok": True}
@router.post("/api/auth/register")
async def register(req: RegisterReq, adm=Depends(require("admin"))):
if req.role not in ("admin","user","viewer"): raise HTTPException(400, "Role inválida")
uid = str(uuid.uuid4())
with db() as c:
if c.execute("SELECT 1 FROM users WHERE username=?", (req.username,)).fetchone(): raise HTTPException(400, "Usuário já existe")
c.execute("INSERT INTO users (id,first_name,last_name,username,email,password_hash,role) VALUES (?,?,?,?,?,?,?)",
(uid, req.first_name, req.last_name, req.username, req.email, _hash_pw(req.password), req.role))
_audit(adm["id"], adm["username"], "create_user", uid, f"user={req.username} role={req.role}")
return {"id": uid, "username": req.username, "role": req.role}
@router.post("/api/auth/change-password")
async def change_pw(req: ChangePwReq, u=Depends(current_user)):
if u.get("auth_provider") == "oidc":
raise HTTPException(400, "Usuários OIDC não podem alterar senha localmente")
if not _verify_pw(req.current_password, u["password_hash"]): raise HTTPException(400, "Senha atual incorreta")
pw = req.new_password
if len(pw) < 8: raise HTTPException(400, "Senha deve ter no mínimo 8 caracteres")
if not re.search(r'[A-Z]', pw): raise HTTPException(400, "Senha deve conter ao menos uma letra maiúscula")
if not re.search(r'[a-z]', pw): raise HTTPException(400, "Senha deve conter ao menos uma letra minúscula")
if not re.search(r'\d', pw): raise HTTPException(400, "Senha deve conter ao menos um número")
if not re.search(r'[^A-Za-z0-9]', pw): raise HTTPException(400, "Senha deve conter ao menos um caractere especial")
if pw == req.current_password: raise HTTPException(400, "A nova senha deve ser diferente da atual")
with db() as c: c.execute("UPDATE users SET password_hash=?, must_change_password=0 WHERE id=?", (_hash_pw(pw), u["id"]))
return {"ok": True}
# ── OIDC endpoints ───────────────────────────────────────────────────────────
@router.get("/api/auth/oidc/config")
async def oidc_public_config():
with db() as c:
row = c.execute("SELECT value FROM app_settings WHERE key='auth_mode'").fetchone()
return {"auth_mode": row["value"] if row else "local"}
@router.get("/api/auth/oidc/login")
async def oidc_login(request: Request):
_check_rate_limit(request.client.host if request.client else "unknown")
cfg = _get_oidc_config()
if cfg.get("auth_mode") not in ("oidc", "both"):
raise HTTPException(400, "OIDC login não está habilitado")
if not cfg.get("oidc_issuer") or not cfg.get("oidc_client_id"):
raise HTTPException(500, "OIDC não configurado (issuer/client_id ausente)")
disco = _oidc_discover(cfg["oidc_issuer"])
state = secrets.token_urlsafe(32)
nonce = secrets.token_urlsafe(32)
_oidc_store_state(state, nonce)
params = {"response_type": "code", "client_id": cfg["oidc_client_id"],
"redirect_uri": cfg["oidc_redirect_uri"], "scope": "openid profile email groups",
"state": state, "nonce": nonce}
auth_url = disco["authorization_endpoint"] + "?" + urlencode(params)
return RedirectResponse(url=auth_url, status_code=302)
@router.get("/api/auth/oidc/callback")
async def oidc_callback(request: Request, code: str = Query(...), state: str = Query(...)):
ip = request.client.host if request.client else "unknown"
_check_rate_limit(ip)
pending = _oidc_pop_state(state)
if not pending:
raise HTTPException(400, "OIDC: state inválido ou expirado")
nonce = pending["nonce"]
cfg = _get_oidc_config()
disco = _oidc_discover(cfg["oidc_issuer"])
import requests as req
token_resp = req.post(disco["token_endpoint"], data={
"grant_type": "authorization_code", "code": code,
"redirect_uri": cfg["oidc_redirect_uri"],
"client_id": cfg["oidc_client_id"], "client_secret": cfg["oidc_client_secret"],
}, timeout=15)
if token_resp.status_code != 200:
from config import log
log.error(f"OIDC token exchange failed: {token_resp.status_code} {token_resp.text[:500]}")
raise HTTPException(502, "OIDC: falha ao trocar código por token")
tokens = token_resp.json()
id_token_raw = tokens.get("id_token")
if not id_token_raw:
raise HTTPException(502, "OIDC: id_token ausente na resposta")
claims = _validate_id_token(id_token_raw, cfg["oidc_issuer"], cfg["oidc_client_id"], nonce)
sub = claims.get("sub")
email = claims.get("email", "")
given_name = claims.get("given_name") or claims.get("name", "").split(" ")[0] or "OIDC"
family_name = claims.get("family_name") or " ".join(claims.get("name", "User").split(" ")[1:]) or "User"
groups = claims.get("groups", [])
if not groups:
groups = claims.get("http://www.oracle.com/groups", [])
role = "viewer"
if cfg.get("oidc_group_admin") and cfg["oidc_group_admin"] in groups:
role = "admin"
elif cfg.get("oidc_group_user") and cfg["oidc_group_user"] in groups:
role = "user"
with db() as c:
u = c.execute("SELECT * FROM users WHERE oidc_subject=? AND is_active=1", (sub,)).fetchone()
if u:
u = dict(u)
c.execute("UPDATE users SET role=?, last_login=datetime('now'), email=? WHERE id=?",
(role, email or u.get("email"), u["id"]))
u["role"] = role
_audit(u["id"], u["username"], "oidc_login", details=f"sub={sub}", ip=ip)
else:
uid = str(uuid.uuid4())
username = email.split("@")[0] if email else f"oidc_{sub[:8]}"
base_username = username
counter = 1
while c.execute("SELECT 1 FROM users WHERE username=?", (username,)).fetchone():
username = f"{base_username}_{counter}"
counter += 1
fake_hash = _hash_pw(secrets.token_hex(32))
c.execute(
"INSERT INTO users (id,first_name,last_name,username,email,password_hash,role,auth_provider,oidc_subject) "
"VALUES (?,?,?,?,?,?,?,?,?)",
(uid, given_name, family_name, username, email, fake_hash, role, "oidc", sub))
u = {"id": uid, "first_name": given_name, "last_name": family_name,
"username": username, "email": email, "role": role}
_audit(uid, username, "oidc_jit_provision", details=f"sub={sub} email={email} role={role}", ip=ip)
c.execute("UPDATE users SET last_login=datetime('now') WHERE id=?", (uid,))
sid = str(uuid.uuid4())
exp = (datetime.utcnow() + timedelta(hours=JWT_EXP_H)).isoformat()
with db() as c:
c.execute("INSERT INTO sessions (id,user_id,expires_at) VALUES (?,?,?)", (sid, u["id"], exp))
token = _make_token(u["id"], u["role"], sid)
html = f"""<!DOCTYPE html><html><head><title>OIDC Login</title></head><body>
<script>localStorage.setItem('t',{json.dumps(token)});window.location.href='/chat';</script>
<noscript><p>Login OK. <a href="/chat">Continuar</a></p></noscript></body></html>"""
return HTMLResponse(content=html)
@router.post("/api/auth/oidc/logout")
async def oidc_logout(u=Depends(current_user)):
with db() as c:
c.execute("UPDATE sessions SET is_active=0 WHERE user_id=?", (u["id"],))
_audit(u["id"], u["username"], "oidc_logout")
idp_logout_url = None
try:
cfg = _get_oidc_config()
if cfg.get("oidc_issuer"):
disco = _oidc_discover(cfg["oidc_issuer"])
idp_logout_url = disco.get("end_session_endpoint")
except Exception:
pass
return {"ok": True, "idp_logout_url": idp_logout_url}
@router.post("/api/settings/oidc/test")
async def test_oidc(u=Depends(require("admin"))):
cfg = _get_oidc_config()
if not cfg.get("oidc_issuer"):
raise HTTPException(400, "Issuer URL não configurado")
import requests as req
try:
url = cfg["oidc_issuer"].rstrip("/") + "/.well-known/openid-configuration"
resp = req.get(url, timeout=10)
resp.raise_for_status()
disco = resp.json()
has_auth = bool(disco.get("authorization_endpoint"))
has_token = bool(disco.get("token_endpoint"))
has_jwks = bool(disco.get("jwks_uri"))
return {"ok": has_auth and has_token and has_jwks, "issuer": disco.get("issuer"),
"authorization_endpoint": has_auth, "token_endpoint": has_token,
"jwks_uri": has_jwks, "end_session_endpoint": bool(disco.get("end_session_endpoint"))}
except Exception as e:
raise HTTPException(502, f"Falha ao conectar ao discovery: {str(e)}")
# ── MFA ───────────────────────────────────────────────────────────────────────
@router.post("/api/mfa/setup")
async def mfa_setup(u=Depends(current_user)):
sec = _totp_secret()
with db() as c: c.execute("UPDATE users SET mfa_secret=? WHERE id=?", (sec, u["id"]))
return {"secret": sec, "uri": _totp_uri(sec, u["username"])}
@router.post("/api/mfa/verify")
async def mfa_verify(req: TOTPVerify, u=Depends(current_user)):
if not u.get("mfa_secret"): raise HTTPException(400, "Chame /api/mfa/setup primeiro")
if not _totp_verify(u["mfa_secret"], req.totp_code): raise HTTPException(400, "Código inválido")
with db() as c: c.execute("UPDATE users SET mfa_enabled=1 WHERE id=?", (u["id"],))
return {"ok": True, "message": "MFA ativado"}
@router.post("/api/mfa/disable/{user_id}")
async def mfa_disable(user_id: str, adm=Depends(require("admin"))):
with db() as c: c.execute("UPDATE users SET mfa_enabled=0,mfa_secret=NULL WHERE id=?", (user_id,))
_audit(adm["id"], adm["username"], "disable_mfa", user_id)
return {"ok": True}