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.
228 lines
13 KiB
Python
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}
|