diff --git a/.env.example b/.env.example index 73460bb..9e52d92 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,19 @@ -# Generate with: openssl rand -hex 32 -APP_SECRET=change-me-generate-a-secure-random-string +# REQUIRED — Generate with: openssl rand -hex 64 +# This key encrypts JWT tokens, Fernet credentials, and session data. +# It MUST be the same across container restarts and all workers. +APP_SECRET= + +# JWT token expiry in hours (default: 12) JWT_EXPIRY_HOURS=12 + +# Frontend port (default: 8080) PORT=8080 + +# CORS allowed origins (comma-separated, e.g., https://your-domain.com) CORS_ORIGINS=http://localhost:8080 + +# Timezone TZ=America/Sao_Paulo + +# Suppress OCI CLI file permission warnings OCI_CLI_SUPPRESS_FILE_PERMISSIONS_WARNING=True diff --git a/README.md b/README.md index c9ff818..03a8e4d 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@

- Version + Version Python FastAPI OCI @@ -23,7 +23,7 @@ AI Agent — Infrastructure & Security Engineer is a self-hosted web application that automates **CIS Oracle Cloud Infrastructure Foundations Benchmark 3.0** compliance checks, powered by **OCI Generative AI** for intelligent analysis and an **MCP (Model Context Protocol)** server architecture for extensible task execution. -The platform combines security compliance scanning, AI-powered chat with **RAG (Retrieval-Augmented Generation)**, infrastructure exploration, and vector-based knowledge storage into a single, containerized solution with a **React 19 SPA** (TypeScript, Vite), **Oracle Dark Premium** theme (light/dark modes), **KPI dashboard** with compliance gauge, **i18n** (pt/en), and **Recharts** visualizations. +The platform combines security compliance scanning, AI-powered chat with **RAG (Retrieval-Augmented Generation)**, infrastructure exploration, and vector-based knowledge storage into a single, containerized solution with a **React 19 SPA** (TypeScript, Vite), **Oracle Dark Premium** theme (light/dark modes), **KPI dashboard** with compliance gauge, **i18n** (pt/en/es), and **Recharts** visualizations. --- @@ -219,13 +219,37 @@ The platform combines security compliance scanning, AI-powered chat with **RAG ( - **KPI Dashboard**: compliance score gauge (SVG semicircular arc with gradient), pass/fail/total KPI cards, donut chart, horizontal bar chart — powered by Recharts - **Animated UI**: staggered fade-in, smooth theme transitions, hover effects, shimmer loading states, pulse alerts +### OCI CLI Terminal +- **Linux-style web terminal** for OCI CLI interaction directly from the browser +- **Config selector**: choose the OCI Config to run commands against +- **Tab autocomplete**: auto-completes OCI CLI commands and subcommands +- **OCID auto-lookup**: paste any OCID (60+ resource types) → auto-detects type and runs `oci get` +- **`find` by name/IP**: search OCI resources by display name or IP address via OCI Search API +- **Command history**: per-user, navigable with arrow keys +- **Help panel**: collapsible help with all commands and keyboard shortcuts +- **State persistence**: terminal output, config selection, and history survive page navigation (Zustand store) +- **User isolation**: commands execute with user's own OCI config only, history is per-user + +### User Management +- **Sub-menu**: Users, My Settings, Oracle IAM +- **My Settings**: per-user timezone, language preference (pt/en/es), password change (local users only), MFA toggle +- **Oracle IAM page**: OIDC configuration UI (issuer, client ID/secret, redirect URI, group-to-role mapping, test connection) +- **Users list**: admin view with role, status, MFA, auth provider badge (OIDC/Local) + ### Security - **JWT authentication** with configurable expiry - **TOTP MFA** (Google Authenticator / Authy compatible) +- **Oracle IAM OIDC**: SSO via Oracle Identity Domains — authorization code flow with PKCE, JWKS signature validation, JIT user provisioning, group-to-role mapping +- **Dual auth modes**: local only, OIDC only, or both simultaneously - **RBAC** with 3 roles: Admin, User, Viewer -- Audit logging for all operations -- Encrypted credential storage -- **Per-user timezone** setting (not global) +- **Fernet encryption** (AES-128-CBC + HMAC-SHA256) for credentials and sensitive settings +- **User isolation**: ownership checks on ~70 endpoints, `is_global` flag for shared resources, private reports, per-user embeddings +- **Export sanitization**: config export strips private keys, passwords, and OCIDs +- **Sensitive settings protection**: `oidc_*`, `secret_*` keys blocked for non-admin users +- Audit logging for all operations (auth, OIDC, JIT provisioning, resource actions) +- Rate limiting on login and OIDC endpoints (10 attempts / 5 min) +- **Per-user timezone and language** settings +- Non-root container execution (`runuser`) --- @@ -493,19 +517,19 @@ Allow group to read buckets in compartment ``` oci-cis-agent/ ├── backend/ -│ ├── app.py # FastAPI application (~9800 lines) +│ ├── app.py # FastAPI application (~10500 lines) │ ├── cis_reports.py # Oracle CIS Benchmark checker (6660 lines, report engine) │ ├── mcp_cis_server.py # MCP server with 12 granular CIS tools (~700 lines) │ ├── gen_tf_reference.py # OCI Terraform provider resource catalog generator │ ├── Dockerfile # Python 3.12 + OCI CLI + Terraform 1.14.7 + Chromium │ └── requirements.txt # Dependencies ├── frontend-react/ -│ ├── src/ # React 19 SPA (TypeScript, 39 source files, ~15800 lines) -│ │ ├── pages/ # 16 page components (Chat, Terraform, Explorer, OCI Services, Reports, Config, Admin) +│ ├── src/ # React 19 SPA (TypeScript, 46 source files, ~19500 lines) +│ │ ├── pages/ # 20 page components (Chat, Terraform, Explorer, Terminal, OCI Services, Reports, Config, Admin) │ │ ├── api/ # API client + endpoint modules -│ │ ├── stores/ # Zustand stores (app state persistence across navigation) +│ │ ├── stores/ # Zustand stores (app, auth, terminal — state persistence across navigation) │ │ ├── hooks/ # Custom hooks (theme, polling) -│ │ └── i18n/ # Internationalization (pt/en, 703 keys) +│ │ └── i18n/ # Internationalization (pt/en/es, 850+ keys) │ └── dist/ # Built SPA served at / ├── nginx/ │ └── default.conf # Reverse proxy (React SPA + API /api/) @@ -528,6 +552,11 @@ oci-cis-agent/ | POST | `/api/auth/logout` | Logout and invalidate session | | POST | `/api/auth/register` | Create user (admin only) | | POST | `/api/auth/change-password` | Change password | +| GET | `/api/auth/oidc/config` | Public auth mode (local/oidc/both) | +| GET | `/api/auth/oidc/login` | Redirect to Oracle Identity Domains | +| GET | `/api/auth/oidc/callback` | OIDC callback (code exchange + JIT provisioning) | +| POST | `/api/auth/oidc/logout` | OIDC logout + IdP logout URL | +| POST | `/api/settings/oidc/test` | Test OIDC discovery (admin) | ### OCI Management @@ -706,10 +735,23 @@ oci-cis-agent/ | Method | Endpoint | Description | |--------|----------|-------------| +| GET | `/api/users/me` | Get current user profile (includes auth_provider) | | GET | `/api/users/me/timezone` | Get user timezone | | PUT | `/api/users/me/timezone` | Set user timezone | +| GET | `/api/users/me/language` | Get user language preference | +| PUT | `/api/users/me/language` | Set user language preference (pt/en/es) | | GET | `/api/settings/timezone/options` | List available timezone options | +### OCI CLI Terminal + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/terminal/execute` | Execute OCI CLI command | +| GET | `/api/terminal/autocomplete` | Tab autocomplete suggestions | +| POST | `/api/terminal/ocid-lookup` | OCID auto-lookup (60+ types) | +| POST | `/api/terminal/find` | Search resources by name/IP | +| GET | `/api/terminal/history` | Get command history (per-user) | + ### CIS Engine | Method | Endpoint | Description | @@ -783,8 +825,8 @@ All models include OCID mapping for `us-ashburn-1`. For other regions, use the " | Component | Technology | |-----------|-----------| | Backend | Python 3.12, FastAPI 0.115, Uvicorn | -| Frontend | React 19 SPA (Vite + TypeScript), Oracle Dark Premium theme, Recharts, i18n (pt/en), rehype-highlight | -| Auth | JWT + TOTP MFA + RBAC | +| Frontend | React 19 SPA (Vite + TypeScript), Oracle Dark Premium theme, Recharts, Zustand, i18n (pt/en/es), rehype-highlight | +| Auth | JWT + TOTP MFA + RBAC + Oracle IAM OIDC | | Database | SQLite (WAL mode) | | OCI SDK | `oci` 2.133.0, `oci-cli` | | ADB | `python-oracledb` 2.4.1 (Thin mode) | @@ -801,6 +843,7 @@ All models include OCID mapping for `us-ashburn-1`. For other regions, use the " | Version | Date | Changes | |---------|------|---------| +| **v3.1** | 2026-04 | **Oracle IAM OIDC**: SSO via Oracle Identity Domains — authorization code flow, JWKS ID token validation (RS256), CSRF protection (state+nonce), JIT user provisioning from OIDC claims, group-to-role mapping (admin/user/viewer), dual auth modes (local/oidc/both), `oidc_client_secret` encrypted with Fernet, rate-limited OIDC endpoints, IdP logout support. **OCI CLI Terminal**: Linux-style web terminal with Tab autocomplete, OCID auto-lookup (60+ resource types), `find` by name/IP via OCI Search API, per-user command history, help panel. **User Management**: sub-menu (Users, My Settings, Oracle IAM), per-user timezone/language/MFA/password change, auth_provider badge (OIDC/Local) in user list. **Spanish i18n**: 3 languages (pt/en/es), 850+ i18n keys. **Security hardening**: Fernet encryption (AES) for credentials (replacing base64), export sanitization (strips private keys/passwords/OCIDs), sensitive settings protection (`oidc_*`/`secret_*` blocked for non-admin). **User isolation**: ownership checks on ~70 endpoints, `is_global` flag for shared ADB/MCP configs, private reports, per-user embeddings with `user_id` metadata. **State persistence**: Terminal and Explorer state survives page navigation via Zustand stores (compartment tree, selected regions, counts, terminal output, history). **175+ API endpoints**, **20 pages**, **~36,500 lines of code**. | | **v3.0** | 2026-03 | **OCI Services page**: new menu item with Service Status tab (auto-detect 6 security services — VSS, Data Safe, Cloud Guard, Bastion, Security Zones, Vault — per tenancy via OCI API + user overrides) and OCI Health tab (real-time Oracle service health from ocistatus.oraclecloud.com, 49 regions, search, geo filters). **Compliance Report v3.0**: OCI Services section with summary table + detailed descriptions + Cloud Guard recommendations, back page (Connect with us + copyright), DOCX download (Pillow camouflage strip, green header tables, result boxes, code blocks, A4 format), compliance_data.json shared between HTML and DOCX, progress bar during RAG generation (X/35, percentage), ADB connection pre-check (fail fast 503). **CIS PDF Chunker**: segments CIS PDF by recommendation number (X.X Ensure...), target 7000 chars with 500-char overlap, filters TOC/appendix/page headers — 54/54 recommendations with complete Description + Rationale + Remediation. **Embedding improvements**: auto-detect dimension from DDL (even on empty tables), auto-detect model per dimension (1536=small, 3072=large), RAG timeout 60s + retry (was 30s), direct SQL fetch by metadata recommendationNumber for overlap chunks. **Per-user timezone** (not global). **New API endpoints**: OCI Health proxy, OCI Services status/overrides, user timezone CRUD, DOCX download, compliance progress details, timezone options. | | **v2.8** | 2026-03 | **Legacy frontend removed**: React SPA now served at root `/` (no more `/app/` prefix), legacy vanilla JS SPA removed. **Compliance Report ZIP download**: Chromium headless PDF generation (identical to browser print) + CSV findings bundled in ZIP. **Compliance generation state persisted server-side**: `.compliance_generating` marker file ensures generation status survives page navigation — spinner auto-resumes on return. **RAG remediation enriched**: extracts Description, Rationale, Audit, and Remediation sections from CIS vector chunks, combines up to 3 matched chunks. **Chat markdown styling**: full markdown rendering with syntax highlighting (Catppuccin Mocha theme via rehype-highlight), styled headings, lists, tables, blockquotes, inline/block code. **Default model auto-select**: Chat Agent auto-selects first GenAI config on page load. **Explorer silent polling**: no more flickering during start/stop resource actions. | | **v2.7** | 2026-03 | **LAD A-Team CIS Compliance Report**: professional Oracle-format compliance report with cover page, TOC, Security Overview (7 pillars), CIS Assessment Summary, detailed findings with compliance % bars, RAG-powered remediation from ADB vector store (`CISRECOM` table with strict recommendation number filtering), affected resources CSV download links per non-compliant finding (JWT auth via query param). **React SPA**: 15-page React 19 frontend (TypeScript, Vite, Zustand, 38 source files). **i18n**: full internationalization with 625 keys (pt/en), language switcher persisted in localStorage. **Report management**: delete reports endpoint, embed individual report files (CSV/TXT/JSON/PDF) into vector store. **Terraform ZIP download**: replaced individual file downloads with client-side ZIP generation (pure JS, no external lib). **Explorer UX**: silent polling during start/stop actions eliminates flickering (resources update in-place without clearing the list). **Report sections collapsed by default**: HTML report and compliance report iframes start minimized. | diff --git a/backend/app.py b/backend/app.py index 841fd09..2f6706c 100644 --- a/backend/app.py +++ b/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"""OIDC Login + +

Login OK. Continuar

""" + 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} diff --git a/docker-compose.yml b/docker-compose.yml index 71594ce..e4de6a6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,7 @@ services: container_name: oci-agent-backend restart: unless-stopped environment: - - APP_SECRET=${APP_SECRET:-change-me-in-production-use-a-long-random-string} + - APP_SECRET=${APP_SECRET:?Set APP_SECRET in .env (openssl rand -hex 64)} - JWT_EXPIRY_HOURS=${JWT_EXPIRY_HOURS:-12} - DATA_DIR=/data - CORS_ORIGINS=${CORS_ORIGINS:-} diff --git a/frontend-react/src/App.tsx b/frontend-react/src/App.tsx index 4471cee..2629fb2 100644 --- a/frontend-react/src/App.tsx +++ b/frontend-react/src/App.tsx @@ -1,9 +1,10 @@ -import { useEffect, lazy, Suspense } from 'react'; +import { useEffect, useState, lazy, Suspense } from 'react'; import { Routes, Route, Navigate } from 'react-router-dom'; import { useAuthStore } from '@/stores/auth'; import { useAppStore } from '@/stores/app'; +import { authApi } from '@/api/endpoints/auth'; import AppShell from '@/components/layout/AppShell'; -import { Loader2 } from 'lucide-react'; +import { Loader2, Lock } from 'lucide-react'; // Lazy-loaded pages (code splitting) const LoginPage = lazy(() => import('@/pages/LoginPage')); @@ -34,10 +35,72 @@ function PageLoader() { ); } +function ForcePasswordModal() { + const [curPw, setCurPw] = useState(''); + const [newPw, setNewPw] = useState(''); + const [confirmPw, setConfirmPw] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const clearMustChangePassword = useAuthStore((s) => s.clearMustChangePassword); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + if (newPw.length < 8) { setError('A nova senha deve ter no minimo 8 caracteres'); return; } + if (newPw !== confirmPw) { setError('As senhas nao coincidem'); return; } + if (newPw === curPw) { setError('A nova senha deve ser diferente da atual'); return; } + setLoading(true); + try { + await authApi.changePassword(curPw, newPw); + clearMustChangePassword(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Erro ao alterar senha'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+
+ +
+

Alterar Senha

+

+ Por seguranca, altere sua senha antes de continuar. +

+
+
+ setCurPw(e.target.value)} + className="w-full px-3 py-2.5 rounded-lg text-sm outline-none" + style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)' }} autoFocus /> + setNewPw(e.target.value)} + className="w-full px-3 py-2.5 rounded-lg text-sm outline-none" + style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)' }} /> + setConfirmPw(e.target.value)} + className="w-full px-3 py-2.5 rounded-lg text-sm outline-none" + style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)' }} /> + {error && ( +
{error}
+ )} + +
+
+
+ ); +} + export default function App() { const checkAuth = useAuthStore((s) => s.checkAuth); const loadData = useAppStore((s) => s.loadData); const user = useAuthStore((s) => s.user); + const mustChangePassword = useAuthStore((s) => s.mustChangePassword); useEffect(() => { checkAuth().then(() => { @@ -51,6 +114,7 @@ export default function App() { return ( }> + {user && mustChangePassword && } } /> }> diff --git a/frontend-react/src/api/endpoints/auth.ts b/frontend-react/src/api/endpoints/auth.ts index 896ed03..7601ce6 100644 --- a/frontend-react/src/api/endpoints/auth.ts +++ b/frontend-react/src/api/endpoints/auth.ts @@ -10,6 +10,7 @@ export interface User { mfa_enabled?: boolean; timezone?: string; auth_provider?: string; + must_change_password?: boolean; } export interface LoginResponse { @@ -18,6 +19,7 @@ export interface LoginResponse { mfa_required?: boolean; mfa_setup?: boolean; totp_uri?: string; + must_change_password?: boolean; } export const authApi = { @@ -26,6 +28,9 @@ export const authApi = { logout: () => client.post('/auth/logout'), + oidcLogout: () => + client.post('/auth/oidc/logout'), + me: () => client.get('/users/me'), changePassword: (current_password: string, new_password: string) => diff --git a/frontend-react/src/i18n/en.ts b/frontend-react/src/i18n/en.ts index b332b49..2adfada 100644 --- a/frontend-react/src/i18n/en.ts +++ b/frontend-react/src/i18n/en.ts @@ -45,6 +45,8 @@ const en: Record = { 'login.verify': 'Verify', 'login.submit': 'Sign in', 'login.error': 'Login failed', + 'login.or': 'or', + 'login.oidcButton': 'Sign in with Oracle IAM', // ── Chat ── 'chat.timeNow': 'now', diff --git a/frontend-react/src/i18n/es.ts b/frontend-react/src/i18n/es.ts index cd8d1e7..ec0ea53 100644 --- a/frontend-react/src/i18n/es.ts +++ b/frontend-react/src/i18n/es.ts @@ -45,6 +45,8 @@ const es: Record = { 'login.verify': 'Verificar', 'login.submit': 'Iniciar sesion', 'login.error': 'Error de inicio de sesion', + 'login.or': 'o', + 'login.oidcButton': 'Iniciar sesion con Oracle IAM', // ── Chat ── 'chat.timeNow': 'ahora', diff --git a/frontend-react/src/i18n/pt.ts b/frontend-react/src/i18n/pt.ts index 96c2af5..3ac2157 100644 --- a/frontend-react/src/i18n/pt.ts +++ b/frontend-react/src/i18n/pt.ts @@ -45,6 +45,8 @@ const pt: Record = { 'login.verify': 'Verificar', 'login.submit': 'Entrar', 'login.error': 'Erro ao fazer login', + 'login.or': 'ou', + 'login.oidcButton': 'Login com Oracle IAM', // ── Chat ── 'chat.timeNow': 'agora', diff --git a/frontend-react/src/pages/LoginPage.tsx b/frontend-react/src/pages/LoginPage.tsx index a7d4c9d..4e9ab69 100644 --- a/frontend-react/src/pages/LoginPage.tsx +++ b/frontend-react/src/pages/LoginPage.tsx @@ -1,5 +1,6 @@ -import { useState, type FormEvent } from 'react'; +import { useState, useEffect, type FormEvent } from 'react'; import { useNavigate } from 'react-router-dom'; +import { Shield } from 'lucide-react'; import { useAuthStore } from '@/stores/auth'; import { useAppStore } from '@/stores/app'; import { useI18n } from '@/i18n'; @@ -15,6 +16,11 @@ export default function LoginPage() { const [totp, setTotp] = useState(''); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); + const [authMode, setAuthMode] = useState('local'); + + useEffect(() => { + fetch('/api/auth/oidc/config').then(r => r.json()).then(d => setAuthMode(d.auth_mode || 'local')).catch(() => {}); + }, []); if (user) { navigate('/chat', { replace: true }); @@ -57,71 +63,94 @@ export default function LoginPage() {

{t('login.subtitle')}

-
- {!mfaRequired ? ( - <> - setUsername(e.target.value)} - className="w-full px-3 py-2.5 rounded-lg text-sm outline-none" - style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)' }} - autoFocus - /> - setPassword(e.target.value)} - className="w-full px-3 py-2.5 rounded-lg text-sm outline-none" - style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)' }} - /> - - ) : ( - <> - {mfaSetup && totpUri && ( -
-

- {t('login.mfaScanQr')} -

- QR Code -
- )} - setTotp(e.target.value)} - className="w-full px-3 py-2.5 rounded-lg text-sm outline-none text-center tracking-[.3em]" - style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)', fontFamily: 'var(--fm)' }} - maxLength={6} - autoFocus - /> - - )} + {authMode !== 'oidc' && ( + + {!mfaRequired ? ( + <> + setUsername(e.target.value)} + className="w-full px-3 py-2.5 rounded-lg text-sm outline-none" + style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)' }} + autoFocus + /> + setPassword(e.target.value)} + className="w-full px-3 py-2.5 rounded-lg text-sm outline-none" + style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)' }} + /> + + ) : ( + <> + {mfaSetup && totpUri && ( +
+

+ {t('login.mfaScanQr')} +

+ QR Code +
+ )} + setTotp(e.target.value)} + className="w-full px-3 py-2.5 rounded-lg text-sm outline-none text-center tracking-[.3em]" + style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)', fontFamily: 'var(--fm)' }} + maxLength={6} + autoFocus + /> + + )} - {error && ( -
- {error} -
- )} + {error && ( +
+ {error} +
+ )} - -
+ + + )} + + {(authMode === 'oidc' || authMode === 'both') && ( + <> + {authMode === 'both' && ( +
+
+ {t('login.or')} +
+
+ )} + + + )}
); diff --git a/frontend-react/src/pages/admin/OracleIamPage.tsx b/frontend-react/src/pages/admin/OracleIamPage.tsx index c58b63c..69bba8f 100644 --- a/frontend-react/src/pages/admin/OracleIamPage.tsx +++ b/frontend-react/src/pages/admin/OracleIamPage.tsx @@ -65,9 +65,8 @@ export default function OracleIamPage() { setSaving(true); try { await Promise.all( - SETTINGS_KEYS.map(k => - client.put(`/settings/${k}`, { value: (config as any)[k] || '' }) - ) + SETTINGS_KEYS.filter(k => !(k === 'oidc_client_secret' && (config as any)[k]?.includes('\u2022'))) + .map(k => client.put(`/settings/${k}`, { value: (config as any)[k] || '' })) ); setMsg({ text: t('iam.saved') || 'Configuração salva com sucesso', type: 'success' }); } catch (err) { @@ -84,12 +83,12 @@ export default function OracleIamPage() { } setTesting(true); try { - const url = config.oidc_issuer.replace(/\/$/, '') + '/.well-known/openid-configuration'; - const resp = await fetch(url); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - const disco = await resp.json(); - if (disco.authorization_endpoint && disco.token_endpoint) { - setMsg({ text: t('iam.testOk') || `Conexão OK — ${disco.issuer}`, type: 'success' }); + const resp = await client.post('/settings/oidc/test') as unknown as { + ok: boolean; issuer: string; + authorization_endpoint: boolean; token_endpoint: boolean; jwks_uri: boolean; + }; + if (resp.ok) { + setMsg({ text: `${t('iam.testOk') || 'Conexão OK'} — ${resp.issuer}`, type: 'success' }); } else { setMsg({ text: t('iam.testInvalid') || 'Resposta inválida do discovery endpoint', type: 'error' }); } diff --git a/frontend-react/src/pages/admin/UsersPage.tsx b/frontend-react/src/pages/admin/UsersPage.tsx index b173e0d..58962f9 100644 --- a/frontend-react/src/pages/admin/UsersPage.tsx +++ b/frontend-react/src/pages/admin/UsersPage.tsx @@ -16,6 +16,7 @@ interface AppUser { is_active: boolean | number; created_at: string | null; last_login: string | null; + auth_provider?: string; } type Role = 'admin' | 'user' | 'viewer'; @@ -409,6 +410,10 @@ export default function UsersPage() { {u.username} + {u.auth_provider === 'oidc' && ( + OIDC + )} {/* Email */} diff --git a/frontend-react/src/stores/auth.ts b/frontend-react/src/stores/auth.ts index 262083f..52b664a 100644 --- a/frontend-react/src/stores/auth.ts +++ b/frontend-react/src/stores/auth.ts @@ -8,11 +8,13 @@ interface AuthState { mfaRequired: boolean; mfaSetup: boolean; totpUri: string | null; + mustChangePassword: boolean; login: (username: string, password: string, totp?: string) => Promise; logout: () => Promise; checkAuth: () => Promise; setToken: (token: string) => void; + clearMustChangePassword: () => void; } export const useAuthStore = create((set, get) => ({ @@ -22,6 +24,7 @@ export const useAuthStore = create((set, get) => ({ mfaRequired: false, mfaSetup: false, totpUri: null, + mustChangePassword: false, login: async (username, password, totp) => { const res = await authApi.login(username, password, totp); @@ -31,15 +34,26 @@ export const useAuthStore = create((set, get) => ({ } if (res.token && res.user) { localStorage.setItem('t', res.token); - set({ token: res.token, user: res.user, mfaRequired: false, mfaSetup: false, totpUri: null }); + set({ token: res.token, user: res.user, mfaRequired: false, mfaSetup: false, totpUri: null, + mustChangePassword: !!res.must_change_password }); } return res; }, logout: async () => { - try { await authApi.logout(); } catch {} + const currentUser = get().user; + try { + if (currentUser?.auth_provider === 'oidc') { + const res = await authApi.oidcLogout(); + localStorage.removeItem('t'); + set({ user: null, token: null, mfaRequired: false, mustChangePassword: false }); + if (res.idp_logout_url) window.location.href = res.idp_logout_url; + return; + } + await authApi.logout(); + } catch {} localStorage.removeItem('t'); - set({ user: null, token: null, mfaRequired: false }); + set({ user: null, token: null, mfaRequired: false, mustChangePassword: false }); }, checkAuth: async () => { @@ -47,7 +61,7 @@ export const useAuthStore = create((set, get) => ({ if (!token) { set({ loading: false }); return; } try { const user = await authApi.me(); - set({ user, loading: false }); + set({ user, loading: false, mustChangePassword: !!user.must_change_password }); } catch { localStorage.removeItem('t'); set({ token: null, user: null, loading: false }); @@ -58,4 +72,6 @@ export const useAuthStore = create((set, get) => ({ localStorage.setItem('t', token); set({ token }); }, + + clearMustChangePassword: () => set({ mustChangePassword: false }), }));