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:
227
backend/routes/auth.py
Normal file
227
backend/routes/auth.py
Normal file
@@ -0,0 +1,227 @@
|
||||
"""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}
|
||||
Reference in New Issue
Block a user