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:
253
backend/app.py
253
backend/app.py
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user