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

@@ -1,7 +1,19 @@
# Generate with: openssl rand -hex 32 # REQUIRED — Generate with: openssl rand -hex 64
APP_SECRET=change-me-generate-a-secure-random-string # 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 JWT_EXPIRY_HOURS=12
# Frontend port (default: 8080)
PORT=8080 PORT=8080
# CORS allowed origins (comma-separated, e.g., https://your-domain.com)
CORS_ORIGINS=http://localhost:8080 CORS_ORIGINS=http://localhost:8080
# Timezone
TZ=America/Sao_Paulo TZ=America/Sao_Paulo
# Suppress OCI CLI file permission warnings
OCI_CLI_SUPPRESS_FILE_PERMISSIONS_WARNING=True OCI_CLI_SUPPRESS_FILE_PERMISSIONS_WARNING=True

View File

@@ -9,7 +9,7 @@
</p> </p>
<p align="center"> <p align="center">
<img src="https://img.shields.io/badge/version-3.0-C74634?style=flat-square" alt="Version"> <img src="https://img.shields.io/badge/version-3.1-C74634?style=flat-square" alt="Version">
<img src="https://img.shields.io/badge/python-3.12-3776AB?style=flat-square" alt="Python"> <img src="https://img.shields.io/badge/python-3.12-3776AB?style=flat-square" alt="Python">
<img src="https://img.shields.io/badge/FastAPI-0.115-009688?style=flat-square" alt="FastAPI"> <img src="https://img.shields.io/badge/FastAPI-0.115-009688?style=flat-square" alt="FastAPI">
<img src="https://img.shields.io/badge/OCI-GenAI-C74634?style=flat-square" alt="OCI"> <img src="https://img.shields.io/badge/OCI-GenAI-C74634?style=flat-square" alt="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. 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 - **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 - **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 <service> <resource> 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 ### Security
- **JWT authentication** with configurable expiry - **JWT authentication** with configurable expiry
- **TOTP MFA** (Google Authenticator / Authy compatible) - **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 - **RBAC** with 3 roles: Admin, User, Viewer
- Audit logging for all operations - **Fernet encryption** (AES-128-CBC + HMAC-SHA256) for credentials and sensitive settings
- Encrypted credential storage - **User isolation**: ownership checks on ~70 endpoints, `is_global` flag for shared resources, private reports, per-user embeddings
- **Per-user timezone** setting (not global) - **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 <group-name> to read buckets in compartment <compartment-name>
``` ```
oci-cis-agent/ oci-cis-agent/
├── backend/ ├── backend/
│ ├── app.py # FastAPI application (~9800 lines) │ ├── app.py # FastAPI application (~10500 lines)
│ ├── cis_reports.py # Oracle CIS Benchmark checker (6660 lines, report engine) │ ├── cis_reports.py # Oracle CIS Benchmark checker (6660 lines, report engine)
│ ├── mcp_cis_server.py # MCP server with 12 granular CIS tools (~700 lines) │ ├── mcp_cis_server.py # MCP server with 12 granular CIS tools (~700 lines)
│ ├── gen_tf_reference.py # OCI Terraform provider resource catalog generator │ ├── gen_tf_reference.py # OCI Terraform provider resource catalog generator
│ ├── Dockerfile # Python 3.12 + OCI CLI + Terraform 1.14.7 + Chromium │ ├── Dockerfile # Python 3.12 + OCI CLI + Terraform 1.14.7 + Chromium
│ └── requirements.txt # Dependencies │ └── requirements.txt # Dependencies
├── frontend-react/ ├── frontend-react/
│ ├── src/ # React 19 SPA (TypeScript, 39 source files, ~15800 lines) │ ├── src/ # React 19 SPA (TypeScript, 46 source files, ~19500 lines)
│ │ ├── pages/ # 16 page components (Chat, Terraform, Explorer, OCI Services, Reports, Config, Admin) │ │ ├── pages/ # 20 page components (Chat, Terraform, Explorer, Terminal, OCI Services, Reports, Config, Admin)
│ │ ├── api/ # API client + endpoint modules │ │ ├── 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) │ │ ├── hooks/ # Custom hooks (theme, polling)
│ │ └── i18n/ # Internationalization (pt/en, 703 keys) │ │ └── i18n/ # Internationalization (pt/en/es, 850+ keys)
│ └── dist/ # Built SPA served at / │ └── dist/ # Built SPA served at /
├── nginx/ ├── nginx/
│ └── default.conf # Reverse proxy (React SPA + API /api/) │ └── 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/logout` | Logout and invalidate session |
| POST | `/api/auth/register` | Create user (admin only) | | POST | `/api/auth/register` | Create user (admin only) |
| POST | `/api/auth/change-password` | Change password | | 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 ### OCI Management
@@ -706,10 +735,23 @@ oci-cis-agent/
| Method | Endpoint | Description | | Method | Endpoint | Description |
|--------|----------|-------------| |--------|----------|-------------|
| GET | `/api/users/me` | Get current user profile (includes auth_provider) |
| GET | `/api/users/me/timezone` | Get user timezone | | GET | `/api/users/me/timezone` | Get user timezone |
| PUT | `/api/users/me/timezone` | Set 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 | | 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 ### CIS Engine
| Method | Endpoint | Description | | Method | Endpoint | Description |
@@ -783,8 +825,8 @@ All models include OCID mapping for `us-ashburn-1`. For other regions, use the "
| Component | Technology | | Component | Technology |
|-----------|-----------| |-----------|-----------|
| Backend | Python 3.12, FastAPI 0.115, Uvicorn | | Backend | Python 3.12, FastAPI 0.115, Uvicorn |
| Frontend | React 19 SPA (Vite + TypeScript), Oracle Dark Premium theme, Recharts, i18n (pt/en), rehype-highlight | | Frontend | React 19 SPA (Vite + TypeScript), Oracle Dark Premium theme, Recharts, Zustand, i18n (pt/en/es), rehype-highlight |
| Auth | JWT + TOTP MFA + RBAC | | Auth | JWT + TOTP MFA + RBAC + Oracle IAM OIDC |
| Database | SQLite (WAL mode) | | Database | SQLite (WAL mode) |
| OCI SDK | `oci` 2.133.0, `oci-cli` | | OCI SDK | `oci` 2.133.0, `oci-cli` |
| ADB | `python-oracledb` 2.4.1 (Thin mode) | | 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 | | 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. | | **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.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. | | **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. |

View File

@@ -17,13 +17,16 @@ from fastapi import (
Query, Body, BackgroundTasks Query, Body, BackgroundTasks
) )
from fastapi.middleware.cors import CORSMiddleware 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 fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel from pydantic import BaseModel
import jwt as pyjwt import jwt as pyjwt
# ── Config ──────────────────────────────────────────────────────────────────── # ── 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_ALG = "HS256"
JWT_EXP_H = int(os.environ.get("JWT_EXPIRY_HOURS", "12")) JWT_EXP_H = int(os.environ.get("JWT_EXPIRY_HOURS", "12"))
DATA = Path(os.environ.get("DATA_DIR", "/data")) 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}") c.execute(f"ALTER TABLE oci_configs ADD COLUMN {col}")
except sqlite3.OperationalError: except sqlite3.OperationalError:
pass 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: try:
c.execute(f"ALTER TABLE users ADD COLUMN {col}") c.execute(f"ALTER TABLE users ADD COLUMN {col}")
except sqlite3.OperationalError: except sqlite3.OperationalError:
@@ -539,11 +542,13 @@ def init_db():
log.info(f"Recovered stuck workspace {ws['id']}: {ws['status']} -> {prev}") log.info(f"Recovered stuck workspace {ws['id']}: {ws['status']} -> {prev}")
adm = c.execute("SELECT id FROM users WHERE username='admin'").fetchone() adm = c.execute("SELECT id FROM users WHERE username='admin'").fetchone()
if not adm: if not adm:
_tmp_pw = secrets.token_urlsafe(16)
c.execute( c.execute(
"INSERT INTO users (id,first_name,last_name,username,email,password_hash,role) VALUES (?,?,?,?,?,?,?)", "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("admin123"), "admin") (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 ──────────────────────────────────────────────────────────── # ── 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()}" 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: except Exception:
return v 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 ───────────────────────────────────────────────────── # ── OCI SDK Client Helper ─────────────────────────────────────────────────────
def _get_oci_config(oci_config_id: str) -> dict: def _get_oci_config(oci_config_id: str) -> dict:
import oci import oci
@@ -776,6 +857,10 @@ def _check_rate_limit(ip: str):
@app.post("/api/auth/login") @app.post("/api/auth/login")
async def login(req: LoginReq, request: Request): async def login(req: LoginReq, request: Request):
_check_rate_limit(request.client.host if request.client else "unknown") _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: with db() as c:
u = c.execute("SELECT * FROM users WHERE username=? AND is_active=1", (req.username,)).fetchone() 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") 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"],)) 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) _audit(u["id"], u["username"], "login", ip=request.client.host if request.client else None)
return {"token":_make_token(u["id"],u["role"],sid), 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") @app.post("/api/auth/logout")
async def logout_ep(u=Depends(current_user)): 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") @app.post("/api/auth/change-password")
async def change_pw(req: ChangePwReq, u=Depends(current_user)): 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") 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} 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 ─────────────────────────────────────────────────────────────────────── # ── MFA ───────────────────────────────────────────────────────────────────────
@app.post("/api/mfa/setup") @app.post("/api/mfa/setup")
async def mfa_setup(u=Depends(current_user)): async def mfa_setup(u=Depends(current_user)):
@@ -836,13 +1056,15 @@ async def mfa_disable(user_id: str, adm=Depends(require("admin"))):
# ── Users ───────────────────────────────────────────────────────────────────── # ── Users ─────────────────────────────────────────────────────────────────────
@app.get("/api/users") @app.get("/api/users")
async def list_users(u=Depends(require("admin"))): 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] return [dict(r) for r in rows]
@app.get("/api/users/me") @app.get("/api/users/me")
async def me(u=Depends(current_user)): async def me(u=Depends(current_user)):
fields = ("id","first_name","last_name","username","email","role","mfa_enabled","timezone","auth_provider") fields = ("id","first_name","last_name","username","email","role","mfa_enabled","timezone","auth_provider","must_change_password")
return {k: u.get(k, "local" if k == "auth_provider" else None) for k in fields} 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}") @app.put("/api/users/{uid}")
async def update_user(uid: str, req: UserUpdateReq, adm=Depends(require("admin"))): 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") raise HTTPException(403, "Acesso negado a configuração sensível")
with db() as c: with db() as c:
row = c.execute("SELECT value FROM app_settings WHERE key=?", (key,)).fetchone() 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}") @app.put("/api/settings/{key}")
async def put_setting(key: str, body: dict, u=Depends(require("admin"))): async def put_setting(key: str, body: dict, u=Depends(require("admin"))):
value = body.get("value", "") 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: 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)) 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} return {"key": key, "value": value}

View File

@@ -6,7 +6,7 @@ services:
container_name: oci-agent-backend container_name: oci-agent-backend
restart: unless-stopped restart: unless-stopped
environment: 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} - JWT_EXPIRY_HOURS=${JWT_EXPIRY_HOURS:-12}
- DATA_DIR=/data - DATA_DIR=/data
- CORS_ORIGINS=${CORS_ORIGINS:-} - CORS_ORIGINS=${CORS_ORIGINS:-}

View File

@@ -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 { Routes, Route, Navigate } from 'react-router-dom';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { useAppStore } from '@/stores/app'; import { useAppStore } from '@/stores/app';
import { authApi } from '@/api/endpoints/auth';
import AppShell from '@/components/layout/AppShell'; import AppShell from '@/components/layout/AppShell';
import { Loader2 } from 'lucide-react'; import { Loader2, Lock } from 'lucide-react';
// Lazy-loaded pages (code splitting) // Lazy-loaded pages (code splitting)
const LoginPage = lazy(() => import('@/pages/LoginPage')); 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 (
<div className="fixed inset-0 z-[9999] flex items-center justify-center" style={{ background: 'rgba(0,0,0,0.7)', backdropFilter: 'blur(4px)' }}>
<div className="w-[400px] p-8 rounded-2xl" style={{ background: 'var(--bg1)', boxShadow: 'var(--sh3)', border: '1px solid var(--bd)' }}>
<div className="flex flex-col items-center mb-5">
<div className="w-12 h-12 rounded-xl flex items-center justify-center mb-3" style={{ background: 'var(--rdl)' }}>
<Lock size={24} style={{ color: 'var(--rd)' }} />
</div>
<h2 className="text-base font-bold" style={{ color: 'var(--t1)' }}>Alterar Senha</h2>
<p className="text-xs mt-1 text-center" style={{ color: 'var(--t3)' }}>
Por seguranca, altere sua senha antes de continuar.
</p>
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
<input type="password" placeholder="Senha atual" value={curPw} onChange={e => 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 />
<input type="password" placeholder="Nova senha (min. 8 caracteres)" value={newPw} onChange={e => 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)' }} />
<input type="password" placeholder="Confirmar nova senha" value={confirmPw} onChange={e => 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 && (
<div className="text-xs px-3 py-2 rounded-lg" style={{ background: 'var(--rdl)', color: 'var(--rd)' }}>{error}</div>
)}
<button type="submit" disabled={loading}
className="w-full py-2.5 rounded-lg text-sm font-semibold text-white transition-colors disabled:opacity-60"
style={{ background: 'var(--ac)' }}>
{loading ? 'Salvando...' : 'Alterar Senha'}
</button>
</form>
</div>
</div>
);
}
export default function App() { export default function App() {
const checkAuth = useAuthStore((s) => s.checkAuth); const checkAuth = useAuthStore((s) => s.checkAuth);
const loadData = useAppStore((s) => s.loadData); const loadData = useAppStore((s) => s.loadData);
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const mustChangePassword = useAuthStore((s) => s.mustChangePassword);
useEffect(() => { useEffect(() => {
checkAuth().then(() => { checkAuth().then(() => {
@@ -51,6 +114,7 @@ export default function App() {
return ( return (
<Suspense fallback={<PageLoader />}> <Suspense fallback={<PageLoader />}>
{user && mustChangePassword && <ForcePasswordModal />}
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route element={<AppShell />}> <Route element={<AppShell />}>

View File

@@ -10,6 +10,7 @@ export interface User {
mfa_enabled?: boolean; mfa_enabled?: boolean;
timezone?: string; timezone?: string;
auth_provider?: string; auth_provider?: string;
must_change_password?: boolean;
} }
export interface LoginResponse { export interface LoginResponse {
@@ -18,6 +19,7 @@ export interface LoginResponse {
mfa_required?: boolean; mfa_required?: boolean;
mfa_setup?: boolean; mfa_setup?: boolean;
totp_uri?: string; totp_uri?: string;
must_change_password?: boolean;
} }
export const authApi = { export const authApi = {
@@ -26,6 +28,9 @@ export const authApi = {
logout: () => client.post('/auth/logout'), logout: () => client.post('/auth/logout'),
oidcLogout: () =>
client.post<never, { ok: boolean; idp_logout_url?: string }>('/auth/oidc/logout'),
me: () => client.get<never, User>('/users/me'), me: () => client.get<never, User>('/users/me'),
changePassword: (current_password: string, new_password: string) => changePassword: (current_password: string, new_password: string) =>

View File

@@ -45,6 +45,8 @@ const en: Record<string, string> = {
'login.verify': 'Verify', 'login.verify': 'Verify',
'login.submit': 'Sign in', 'login.submit': 'Sign in',
'login.error': 'Login failed', 'login.error': 'Login failed',
'login.or': 'or',
'login.oidcButton': 'Sign in with Oracle IAM',
// ── Chat ── // ── Chat ──
'chat.timeNow': 'now', 'chat.timeNow': 'now',

View File

@@ -45,6 +45,8 @@ const es: Record<string, string> = {
'login.verify': 'Verificar', 'login.verify': 'Verificar',
'login.submit': 'Iniciar sesion', 'login.submit': 'Iniciar sesion',
'login.error': 'Error de inicio de sesion', 'login.error': 'Error de inicio de sesion',
'login.or': 'o',
'login.oidcButton': 'Iniciar sesion con Oracle IAM',
// ── Chat ── // ── Chat ──
'chat.timeNow': 'ahora', 'chat.timeNow': 'ahora',

View File

@@ -45,6 +45,8 @@ const pt: Record<string, string> = {
'login.verify': 'Verificar', 'login.verify': 'Verificar',
'login.submit': 'Entrar', 'login.submit': 'Entrar',
'login.error': 'Erro ao fazer login', 'login.error': 'Erro ao fazer login',
'login.or': 'ou',
'login.oidcButton': 'Login com Oracle IAM',
// ── Chat ── // ── Chat ──
'chat.timeNow': 'agora', 'chat.timeNow': 'agora',

View File

@@ -1,5 +1,6 @@
import { useState, type FormEvent } from 'react'; import { useState, useEffect, type FormEvent } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Shield } from 'lucide-react';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { useAppStore } from '@/stores/app'; import { useAppStore } from '@/stores/app';
import { useI18n } from '@/i18n'; import { useI18n } from '@/i18n';
@@ -15,6 +16,11 @@ export default function LoginPage() {
const [totp, setTotp] = useState(''); const [totp, setTotp] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loading, setLoading] = useState(false); 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) { if (user) {
navigate('/chat', { replace: true }); navigate('/chat', { replace: true });
@@ -57,71 +63,94 @@ export default function LoginPage() {
<p className="text-xs mt-1" style={{ color: 'var(--t3)' }}>{t('login.subtitle')}</p> <p className="text-xs mt-1" style={{ color: 'var(--t3)' }}>{t('login.subtitle')}</p>
</div> </div>
<form onSubmit={handleSubmit} className="flex flex-col gap-3"> {authMode !== 'oidc' && (
{!mfaRequired ? ( <form onSubmit={handleSubmit} className="flex flex-col gap-3">
<> {!mfaRequired ? (
<input <>
type="text" <input
placeholder={t('login.userPlaceholder')} type="text"
value={username} placeholder={t('login.userPlaceholder')}
onChange={(e) => setUsername(e.target.value)} value={username}
className="w-full px-3 py-2.5 rounded-lg text-sm outline-none" onChange={(e) => setUsername(e.target.value)}
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)' }} className="w-full px-3 py-2.5 rounded-lg text-sm outline-none"
autoFocus style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)' }}
/> autoFocus
<input />
type="password" <input
placeholder={t('login.passPlaceholder')} type="password"
value={password} placeholder={t('login.passPlaceholder')}
onChange={(e) => setPassword(e.target.value)} value={password}
className="w-full px-3 py-2.5 rounded-lg text-sm outline-none" onChange={(e) => setPassword(e.target.value)}
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)' }} 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 && ( <>
<div className="text-center mb-2"> {mfaSetup && totpUri && (
<p className="text-xs mb-2" style={{ color: 'var(--t3)' }}> <div className="text-center mb-2">
{t('login.mfaScanQr')} <p className="text-xs mb-2" style={{ color: 'var(--t3)' }}>
</p> {t('login.mfaScanQr')}
<img </p>
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(totpUri)}`} <img
alt="QR Code" src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(totpUri)}`}
className="mx-auto rounded-lg" alt="QR Code"
width={180} className="mx-auto rounded-lg"
height={180} width={180}
/> height={180}
</div> />
)} </div>
<input )}
type="text" <input
placeholder={t('login.totpPlaceholder')} type="text"
value={totp} placeholder={t('login.totpPlaceholder')}
onChange={(e) => setTotp(e.target.value)} value={totp}
className="w-full px-3 py-2.5 rounded-lg text-sm outline-none text-center tracking-[.3em]" onChange={(e) => setTotp(e.target.value)}
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)', fontFamily: 'var(--fm)' }} className="w-full px-3 py-2.5 rounded-lg text-sm outline-none text-center tracking-[.3em]"
maxLength={6} style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)', fontFamily: 'var(--fm)' }}
autoFocus maxLength={6}
/> autoFocus
</> />
)} </>
)}
{error && ( {error && (
<div className="text-xs px-3 py-2 rounded-lg" style={{ background: 'var(--rdl)', color: 'var(--rd)' }}> <div className="text-xs px-3 py-2 rounded-lg" style={{ background: 'var(--rdl)', color: 'var(--rd)' }}>
{error} {error}
</div> </div>
)} )}
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}
className="w-full py-2.5 rounded-lg text-sm font-semibold text-white transition-colors disabled:opacity-60" className="w-full py-2.5 rounded-lg text-sm font-semibold text-white transition-colors disabled:opacity-60"
style={{ background: 'var(--ac)' }} style={{ background: 'var(--ac)' }}
> >
{loading ? t('login.loading') : mfaRequired ? t('login.verify') : t('login.submit')} {loading ? t('login.loading') : mfaRequired ? t('login.verify') : t('login.submit')}
</button> </button>
</form> </form>
)}
{(authMode === 'oidc' || authMode === 'both') && (
<>
{authMode === 'both' && (
<div className="flex items-center gap-2 my-3">
<div className="flex-1 h-px" style={{ background: 'var(--bd)' }} />
<span className="text-[.65rem]" style={{ color: 'var(--t4)' }}>{t('login.or')}</span>
<div className="flex-1 h-px" style={{ background: 'var(--bd)' }} />
</div>
)}
<button
type="button"
onClick={() => { window.location.href = '/api/auth/oidc/login'; }}
className="w-full py-2.5 rounded-lg text-sm font-semibold transition-colors flex items-center justify-center gap-2"
style={{ background: 'var(--rdl)', color: 'var(--rd)', border: '1px solid color-mix(in srgb, var(--rd) 30%, transparent)' }}
>
<Shield size={16} />
{t('login.oidcButton')}
</button>
</>
)}
</div> </div>
</div> </div>
); );

View File

@@ -65,9 +65,8 @@ export default function OracleIamPage() {
setSaving(true); setSaving(true);
try { try {
await Promise.all( await Promise.all(
SETTINGS_KEYS.map(k => SETTINGS_KEYS.filter(k => !(k === 'oidc_client_secret' && (config as any)[k]?.includes('\u2022')))
client.put(`/settings/${k}`, { value: (config as any)[k] || '' }) .map(k => client.put(`/settings/${k}`, { value: (config as any)[k] || '' }))
)
); );
setMsg({ text: t('iam.saved') || 'Configuração salva com sucesso', type: 'success' }); setMsg({ text: t('iam.saved') || 'Configuração salva com sucesso', type: 'success' });
} catch (err) { } catch (err) {
@@ -84,12 +83,12 @@ export default function OracleIamPage() {
} }
setTesting(true); setTesting(true);
try { try {
const url = config.oidc_issuer.replace(/\/$/, '') + '/.well-known/openid-configuration'; const resp = await client.post('/settings/oidc/test') as unknown as {
const resp = await fetch(url); ok: boolean; issuer: string;
if (!resp.ok) throw new Error(`HTTP ${resp.status}`); authorization_endpoint: boolean; token_endpoint: boolean; jwks_uri: boolean;
const disco = await resp.json(); };
if (disco.authorization_endpoint && disco.token_endpoint) { if (resp.ok) {
setMsg({ text: t('iam.testOk') || `Conexão OK — ${disco.issuer}`, type: 'success' }); setMsg({ text: `${t('iam.testOk') || 'Conexão OK'}${resp.issuer}`, type: 'success' });
} else { } else {
setMsg({ text: t('iam.testInvalid') || 'Resposta inválida do discovery endpoint', type: 'error' }); setMsg({ text: t('iam.testInvalid') || 'Resposta inválida do discovery endpoint', type: 'error' });
} }

View File

@@ -16,6 +16,7 @@ interface AppUser {
is_active: boolean | number; is_active: boolean | number;
created_at: string | null; created_at: string | null;
last_login: string | null; last_login: string | null;
auth_provider?: string;
} }
type Role = 'admin' | 'user' | 'viewer'; type Role = 'admin' | 'user' | 'viewer';
@@ -409,6 +410,10 @@ export default function UsersPage() {
<span className="text-xs" style={{ color: 'var(--t2)', fontFamily: 'var(--fm)' }}> <span className="text-xs" style={{ color: 'var(--t2)', fontFamily: 'var(--fm)' }}>
{u.username} {u.username}
</span> </span>
{u.auth_provider === 'oidc' && (
<span className="ml-1.5 text-[.55rem] font-bold px-1.5 py-0.5 rounded"
style={{ background: 'var(--rdl)', color: 'var(--rd)' }}>OIDC</span>
)}
</td> </td>
{/* Email */} {/* Email */}

View File

@@ -8,11 +8,13 @@ interface AuthState {
mfaRequired: boolean; mfaRequired: boolean;
mfaSetup: boolean; mfaSetup: boolean;
totpUri: string | null; totpUri: string | null;
mustChangePassword: boolean;
login: (username: string, password: string, totp?: string) => Promise<LoginResponse>; login: (username: string, password: string, totp?: string) => Promise<LoginResponse>;
logout: () => Promise<void>; logout: () => Promise<void>;
checkAuth: () => Promise<void>; checkAuth: () => Promise<void>;
setToken: (token: string) => void; setToken: (token: string) => void;
clearMustChangePassword: () => void;
} }
export const useAuthStore = create<AuthState>((set, get) => ({ export const useAuthStore = create<AuthState>((set, get) => ({
@@ -22,6 +24,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
mfaRequired: false, mfaRequired: false,
mfaSetup: false, mfaSetup: false,
totpUri: null, totpUri: null,
mustChangePassword: false,
login: async (username, password, totp) => { login: async (username, password, totp) => {
const res = await authApi.login(username, password, totp); const res = await authApi.login(username, password, totp);
@@ -31,15 +34,26 @@ export const useAuthStore = create<AuthState>((set, get) => ({
} }
if (res.token && res.user) { if (res.token && res.user) {
localStorage.setItem('t', res.token); 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; return res;
}, },
logout: async () => { 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'); localStorage.removeItem('t');
set({ user: null, token: null, mfaRequired: false }); set({ user: null, token: null, mfaRequired: false, mustChangePassword: false });
}, },
checkAuth: async () => { checkAuth: async () => {
@@ -47,7 +61,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
if (!token) { set({ loading: false }); return; } if (!token) { set({ loading: false }); return; }
try { try {
const user = await authApi.me(); const user = await authApi.me();
set({ user, loading: false }); set({ user, loading: false, mustChangePassword: !!user.must_change_password });
} catch { } catch {
localStorage.removeItem('t'); localStorage.removeItem('t');
set({ token: null, user: null, loading: false }); set({ token: null, user: null, loading: false });
@@ -58,4 +72,6 @@ export const useAuthStore = create<AuthState>((set, get) => ({
localStorage.setItem('t', token); localStorage.setItem('t', token);
set({ token }); set({ token });
}, },
clearMustChangePassword: () => set({ mustChangePassword: false }),
})); }));