feat: Oracle IAM OIDC, force password change, security hardening

- OIDC: authorization code flow, JWKS validation, JIT provisioning, group-to-role mapping, dual auth (local/oidc/both)
- Force password change: random admin password on first install, modal blocks UI until changed
- APP_SECRET required in docker-compose (fail if missing), .env.example improved
- Login page: Oracle IAM button (conditional), hide form in oidc-only mode
- OracleIamPage: test connection via backend, masked client_secret handling
- UsersPage: OIDC badge, auth_provider in list
- i18n: login.oidcButton, login.or (pt/en/es)
- README v3.1: Terminal, User Management, Security, OIDC endpoints, versioning
This commit is contained in:
nogueiraguh
2026-04-02 10:26:00 -03:00
parent 60596e790f
commit f22bb54e25
13 changed files with 514 additions and 106 deletions

View File

@@ -17,13 +17,16 @@ from fastapi import (
Query, Body, BackgroundTasks
)
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, FileResponse, StreamingResponse, HTMLResponse, Response
from fastapi.responses import JSONResponse, FileResponse, StreamingResponse, HTMLResponse, Response, RedirectResponse
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel
import jwt as pyjwt
# ── Config ────────────────────────────────────────────────────────────────────
APP_SECRET = os.environ.get("APP_SECRET", secrets.token_hex(32))
APP_SECRET = os.environ.get("APP_SECRET") or secrets.token_hex(32)
if not os.environ.get("APP_SECRET"):
import warnings
warnings.warn("APP_SECRET not set — using random key (tokens will not survive restarts). Set APP_SECRET in .env for production.")
JWT_ALG = "HS256"
JWT_EXP_H = int(os.environ.get("JWT_EXPIRY_HOURS", "12"))
DATA = Path(os.environ.get("DATA_DIR", "/data"))
@@ -471,7 +474,7 @@ def init_db():
c.execute(f"ALTER TABLE oci_configs ADD COLUMN {col}")
except sqlite3.OperationalError:
pass
for col in ["timezone TEXT DEFAULT 'America/Sao_Paulo'", "auth_provider TEXT DEFAULT 'local'", "oidc_subject TEXT"]:
for col in ["timezone TEXT DEFAULT 'America/Sao_Paulo'", "auth_provider TEXT DEFAULT 'local'", "oidc_subject TEXT", "must_change_password INTEGER DEFAULT 0"]:
try:
c.execute(f"ALTER TABLE users ADD COLUMN {col}")
except sqlite3.OperationalError:
@@ -539,11 +542,13 @@ def init_db():
log.info(f"Recovered stuck workspace {ws['id']}: {ws['status']} -> {prev}")
adm = c.execute("SELECT id FROM users WHERE username='admin'").fetchone()
if not adm:
_tmp_pw = secrets.token_urlsafe(16)
c.execute(
"INSERT INTO users (id,first_name,last_name,username,email,password_hash,role) VALUES (?,?,?,?,?,?,?)",
(str(uuid.uuid4()), "Admin", "Sistema", "admin", "admin@local", _hash_pw("admin123"), "admin")
"INSERT INTO users (id,first_name,last_name,username,email,password_hash,role,must_change_password) VALUES (?,?,?,?,?,?,?,?)",
(str(uuid.uuid4()), "Admin", "Sistema", "admin", "admin@local", _hash_pw(_tmp_pw), "admin", 1)
)
log.info("Default admin created: admin / admin123")
log.info(f"Default admin created — username: admin / password: {_tmp_pw}")
log.info("⚠ Change the admin password on first login!")
# ── Crypto helpers ────────────────────────────────────────────────────────────
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()}"
@@ -597,6 +602,82 @@ def _safe_dec(v):
except Exception:
return v
# ── OIDC helpers ─────────────────────────────────────────────────────────────
_oidc_discovery_cache: dict = {} # {issuer: (data, timestamp)}
_oidc_jwks_cache: dict = {} # {jwks_uri: (keys, timestamp)}
_OIDC_CACHE_TTL = 3600 # 1 hour
_oidc_pending: dict = {} # {state: {"nonce": str, "created": float}}
_oidc_pending_lock = threading.Lock()
_OIDC_STATE_TTL = 300 # 5 minutes
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
# ── OCI SDK Client Helper ─────────────────────────────────────────────────────
def _get_oci_config(oci_config_id: str) -> dict:
import oci
@@ -776,6 +857,10 @@ def _check_rate_limit(ip: str):
@app.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")
@@ -789,7 +874,8 @@ async def login(req: LoginReq, request: Request):
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")}}
"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))}
@app.post("/api/auth/logout")
async def logout_ep(u=Depends(current_user)):
@@ -809,10 +895,144 @@ async def register(req: RegisterReq, adm=Depends(require("admin"))):
@app.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")
with db() as c: c.execute("UPDATE users SET password_hash=? WHERE id=?", (_hash_pw(req.new_password), u["id"]))
with db() as c: c.execute("UPDATE users SET password_hash=?, must_change_password=0 WHERE id=?", (_hash_pw(req.new_password), u["id"]))
return {"ok": True}
# ── OIDC endpoints ───────────────────────────────────────────────────────────
@app.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"}
@app.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)
from urllib.parse import urlencode
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)
@app.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:
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)
@app.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}
@app.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 ───────────────────────────────────────────────────────────────────────
@app.post("/api/mfa/setup")
async def mfa_setup(u=Depends(current_user)):
@@ -836,13 +1056,15 @@ async def mfa_disable(user_id: str, adm=Depends(require("admin"))):
# ── Users ─────────────────────────────────────────────────────────────────────
@app.get("/api/users")
async def list_users(u=Depends(require("admin"))):
with db() as c: rows = c.execute("SELECT id,first_name,last_name,username,email,role,mfa_enabled,timezone,is_active,created_at,last_login FROM users").fetchall()
with db() as c: rows = c.execute("SELECT id,first_name,last_name,username,email,role,mfa_enabled,timezone,is_active,created_at,last_login,auth_provider FROM users").fetchall()
return [dict(r) for r in rows]
@app.get("/api/users/me")
async def me(u=Depends(current_user)):
fields = ("id","first_name","last_name","username","email","role","mfa_enabled","timezone","auth_provider")
return {k: u.get(k, "local" if k == "auth_provider" else None) for k in fields}
fields = ("id","first_name","last_name","username","email","role","mfa_enabled","timezone","auth_provider","must_change_password")
r = {k: u.get(k, "local" if k == "auth_provider" else None) for k in fields}
r["must_change_password"] = bool(r.get("must_change_password", 0))
return r
@app.put("/api/users/{uid}")
async def update_user(uid: str, req: UserUpdateReq, adm=Depends(require("admin"))):
@@ -9976,11 +10198,18 @@ async def get_setting(key: str, u=Depends(current_user)):
raise HTTPException(403, "Acesso negado a configuração sensível")
with db() as c:
row = c.execute("SELECT value FROM app_settings WHERE key=?", (key,)).fetchone()
return {"key": key, "value": row["value"] if row else ""}
val = row["value"] if row else ""
if key == "oidc_client_secret" and val:
val = _mask(_safe_dec(val))
return {"key": key, "value": val}
@app.put("/api/settings/{key}")
async def put_setting(key: str, body: dict, u=Depends(require("admin"))):
value = body.get("value", "")
if key == "oidc_client_secret" and value and "\u2022" in value:
return {"key": key, "value": value}
if key == "oidc_client_secret" and value:
value = _enc(value)
with db() as c:
c.execute("INSERT INTO app_settings (key,value,updated_at) VALUES (?,?,datetime('now')) ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at", (key, value))
return {"key": key, "value": value}