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:
16
.env.example
16
.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
|
||||
|
||||
67
README.md
67
README.md
@@ -9,7 +9,7 @@
|
||||
</p>
|
||||
|
||||
<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/FastAPI-0.115-009688?style=flat-square" alt="FastAPI">
|
||||
<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.
|
||||
|
||||
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 <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
|
||||
- **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 <group-name> to read buckets in compartment <compartment-name>
|
||||
```
|
||||
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. |
|
||||
|
||||
253
backend/app.py
253
backend/app.py
@@ -17,13 +17,16 @@ from fastapi import (
|
||||
Query, Body, BackgroundTasks
|
||||
)
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse, FileResponse, StreamingResponse, HTMLResponse, Response
|
||||
from fastapi.responses import JSONResponse, FileResponse, StreamingResponse, HTMLResponse, Response, RedirectResponse
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from pydantic import BaseModel
|
||||
import jwt as pyjwt
|
||||
|
||||
# ── Config ────────────────────────────────────────────────────────────────────
|
||||
APP_SECRET = os.environ.get("APP_SECRET", secrets.token_hex(32))
|
||||
APP_SECRET = os.environ.get("APP_SECRET") or secrets.token_hex(32)
|
||||
if not os.environ.get("APP_SECRET"):
|
||||
import warnings
|
||||
warnings.warn("APP_SECRET not set — using random key (tokens will not survive restarts). Set APP_SECRET in .env for production.")
|
||||
JWT_ALG = "HS256"
|
||||
JWT_EXP_H = int(os.environ.get("JWT_EXPIRY_HOURS", "12"))
|
||||
DATA = Path(os.environ.get("DATA_DIR", "/data"))
|
||||
@@ -471,7 +474,7 @@ def init_db():
|
||||
c.execute(f"ALTER TABLE oci_configs ADD COLUMN {col}")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
for col in ["timezone TEXT DEFAULT 'America/Sao_Paulo'", "auth_provider TEXT DEFAULT 'local'", "oidc_subject TEXT"]:
|
||||
for col in ["timezone TEXT DEFAULT 'America/Sao_Paulo'", "auth_provider TEXT DEFAULT 'local'", "oidc_subject TEXT", "must_change_password INTEGER DEFAULT 0"]:
|
||||
try:
|
||||
c.execute(f"ALTER TABLE users ADD COLUMN {col}")
|
||||
except sqlite3.OperationalError:
|
||||
@@ -539,11 +542,13 @@ def init_db():
|
||||
log.info(f"Recovered stuck workspace {ws['id']}: {ws['status']} -> {prev}")
|
||||
adm = c.execute("SELECT id FROM users WHERE username='admin'").fetchone()
|
||||
if not adm:
|
||||
_tmp_pw = secrets.token_urlsafe(16)
|
||||
c.execute(
|
||||
"INSERT INTO users (id,first_name,last_name,username,email,password_hash,role) VALUES (?,?,?,?,?,?,?)",
|
||||
(str(uuid.uuid4()), "Admin", "Sistema", "admin", "admin@local", _hash_pw("admin123"), "admin")
|
||||
"INSERT INTO users (id,first_name,last_name,username,email,password_hash,role,must_change_password) VALUES (?,?,?,?,?,?,?,?)",
|
||||
(str(uuid.uuid4()), "Admin", "Sistema", "admin", "admin@local", _hash_pw(_tmp_pw), "admin", 1)
|
||||
)
|
||||
log.info("Default admin created: admin / admin123")
|
||||
log.info(f"Default admin created — username: admin / password: {_tmp_pw}")
|
||||
log.info("⚠ Change the admin password on first login!")
|
||||
|
||||
# ── Crypto helpers ────────────────────────────────────────────────────────────
|
||||
def _hash_pw(pw): salt=secrets.token_hex(16); h=hashlib.pbkdf2_hmac("sha256",pw.encode(),salt.encode(),100_000); return f"{salt}:{h.hex()}"
|
||||
@@ -597,6 +602,82 @@ def _safe_dec(v):
|
||||
except Exception:
|
||||
return v
|
||||
|
||||
# ── OIDC helpers ─────────────────────────────────────────────────────────────
|
||||
_oidc_discovery_cache: dict = {} # {issuer: (data, timestamp)}
|
||||
_oidc_jwks_cache: dict = {} # {jwks_uri: (keys, timestamp)}
|
||||
_OIDC_CACHE_TTL = 3600 # 1 hour
|
||||
_oidc_pending: dict = {} # {state: {"nonce": str, "created": float}}
|
||||
_oidc_pending_lock = threading.Lock()
|
||||
_OIDC_STATE_TTL = 300 # 5 minutes
|
||||
|
||||
def _get_oidc_config() -> dict:
|
||||
keys = ("auth_mode","oidc_issuer","oidc_client_id","oidc_client_secret",
|
||||
"oidc_redirect_uri","oidc_group_admin","oidc_group_user","oidc_group_viewer")
|
||||
cfg = {}
|
||||
with db() as c:
|
||||
for k in keys:
|
||||
row = c.execute("SELECT value FROM app_settings WHERE key=?", (k,)).fetchone()
|
||||
cfg[k] = row["value"] if row else ""
|
||||
if cfg.get("oidc_client_secret"):
|
||||
cfg["oidc_client_secret"] = _safe_dec(cfg["oidc_client_secret"])
|
||||
return cfg
|
||||
|
||||
def _oidc_discover(issuer: str) -> dict:
|
||||
import requests as req
|
||||
now = time.time()
|
||||
cached = _oidc_discovery_cache.get(issuer)
|
||||
if cached and now - cached[1] < _OIDC_CACHE_TTL:
|
||||
return cached[0]
|
||||
url = issuer.rstrip("/") + "/.well-known/openid-configuration"
|
||||
resp = req.get(url, timeout=10)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
_oidc_discovery_cache[issuer] = (data, now)
|
||||
return data
|
||||
|
||||
def _oidc_get_jwks(jwks_uri: str) -> list:
|
||||
import requests as req
|
||||
now = time.time()
|
||||
cached = _oidc_jwks_cache.get(jwks_uri)
|
||||
if cached and now - cached[1] < _OIDC_CACHE_TTL:
|
||||
return cached[0]
|
||||
resp = req.get(jwks_uri, timeout=10)
|
||||
resp.raise_for_status()
|
||||
keys = resp.json().get("keys", [])
|
||||
_oidc_jwks_cache[jwks_uri] = (keys, now)
|
||||
return keys
|
||||
|
||||
def _oidc_store_state(state: str, nonce: str):
|
||||
with _oidc_pending_lock:
|
||||
now = time.time()
|
||||
expired = [k for k, v in _oidc_pending.items() if now - v["created"] > _OIDC_STATE_TTL]
|
||||
for k in expired:
|
||||
del _oidc_pending[k]
|
||||
_oidc_pending[state] = {"nonce": nonce, "created": now}
|
||||
|
||||
def _oidc_pop_state(state: str) -> dict | None:
|
||||
with _oidc_pending_lock:
|
||||
return _oidc_pending.pop(state, None)
|
||||
|
||||
def _validate_id_token(id_token: str, issuer: str, client_id: str, nonce: str) -> dict:
|
||||
from jwt.algorithms import RSAAlgorithm
|
||||
disco = _oidc_discover(issuer)
|
||||
jwks = _oidc_get_jwks(disco["jwks_uri"])
|
||||
header = pyjwt.get_unverified_header(id_token)
|
||||
kid = header.get("kid")
|
||||
key_data = None
|
||||
for k in jwks:
|
||||
if k.get("kid") == kid:
|
||||
key_data = k
|
||||
break
|
||||
if not key_data:
|
||||
raise HTTPException(401, "OIDC: chave não encontrada no JWKS")
|
||||
public_key = RSAAlgorithm.from_jwk(json.dumps(key_data))
|
||||
claims = pyjwt.decode(id_token, public_key, algorithms=["RS256"], audience=client_id, issuer=issuer)
|
||||
if claims.get("nonce") != nonce:
|
||||
raise HTTPException(401, "OIDC: nonce inválido")
|
||||
return claims
|
||||
|
||||
# ── OCI SDK Client Helper ─────────────────────────────────────────────────────
|
||||
def _get_oci_config(oci_config_id: str) -> dict:
|
||||
import oci
|
||||
@@ -776,6 +857,10 @@ def _check_rate_limit(ip: str):
|
||||
@app.post("/api/auth/login")
|
||||
async def login(req: LoginReq, request: Request):
|
||||
_check_rate_limit(request.client.host if request.client else "unknown")
|
||||
with db() as c:
|
||||
mode_row = c.execute("SELECT value FROM app_settings WHERE key='auth_mode'").fetchone()
|
||||
if mode_row and mode_row["value"] == "oidc":
|
||||
raise HTTPException(400, "Login local desabilitado. Use Oracle IAM.")
|
||||
with db() as c:
|
||||
u = c.execute("SELECT * FROM users WHERE username=? AND is_active=1", (req.username,)).fetchone()
|
||||
if not u or not _verify_pw(req.password, u["password_hash"]): raise HTTPException(401, "Credenciais inválidas")
|
||||
@@ -789,7 +874,8 @@ async def login(req: LoginReq, request: Request):
|
||||
c.execute("UPDATE users SET last_login=datetime('now') WHERE id=?", (u["id"],))
|
||||
_audit(u["id"], u["username"], "login", ip=request.client.host if request.client else None)
|
||||
return {"token":_make_token(u["id"],u["role"],sid),
|
||||
"user":{"id":u["id"],"first_name":u["first_name"],"last_name":u["last_name"],"username":u["username"],"email":u["email"],"role":u["role"],"mfa_enabled":bool(u["mfa_enabled"]),"timezone":u.get("timezone","America/Sao_Paulo")}}
|
||||
"user":{"id":u["id"],"first_name":u["first_name"],"last_name":u["last_name"],"username":u["username"],"email":u["email"],"role":u["role"],"mfa_enabled":bool(u["mfa_enabled"]),"timezone":u.get("timezone","America/Sao_Paulo")},
|
||||
"must_change_password": bool(u.get("must_change_password", 0))}
|
||||
|
||||
@app.post("/api/auth/logout")
|
||||
async def logout_ep(u=Depends(current_user)):
|
||||
@@ -809,10 +895,144 @@ async def register(req: RegisterReq, adm=Depends(require("admin"))):
|
||||
|
||||
@app.post("/api/auth/change-password")
|
||||
async def change_pw(req: ChangePwReq, u=Depends(current_user)):
|
||||
if u.get("auth_provider") == "oidc":
|
||||
raise HTTPException(400, "Usuários OIDC não podem alterar senha localmente")
|
||||
if not _verify_pw(req.current_password, u["password_hash"]): raise HTTPException(400, "Senha atual incorreta")
|
||||
with db() as c: c.execute("UPDATE users SET password_hash=? WHERE id=?", (_hash_pw(req.new_password), u["id"]))
|
||||
with db() as c: c.execute("UPDATE users SET password_hash=?, must_change_password=0 WHERE id=?", (_hash_pw(req.new_password), u["id"]))
|
||||
return {"ok": True}
|
||||
|
||||
# ── OIDC endpoints ───────────────────────────────────────────────────────────
|
||||
@app.get("/api/auth/oidc/config")
|
||||
async def oidc_public_config():
|
||||
with db() as c:
|
||||
row = c.execute("SELECT value FROM app_settings WHERE key='auth_mode'").fetchone()
|
||||
return {"auth_mode": row["value"] if row else "local"}
|
||||
|
||||
@app.get("/api/auth/oidc/login")
|
||||
async def oidc_login(request: Request):
|
||||
_check_rate_limit(request.client.host if request.client else "unknown")
|
||||
cfg = _get_oidc_config()
|
||||
if cfg.get("auth_mode") not in ("oidc", "both"):
|
||||
raise HTTPException(400, "OIDC login não está habilitado")
|
||||
if not cfg.get("oidc_issuer") or not cfg.get("oidc_client_id"):
|
||||
raise HTTPException(500, "OIDC não configurado (issuer/client_id ausente)")
|
||||
disco = _oidc_discover(cfg["oidc_issuer"])
|
||||
state = secrets.token_urlsafe(32)
|
||||
nonce = secrets.token_urlsafe(32)
|
||||
_oidc_store_state(state, nonce)
|
||||
from urllib.parse import urlencode
|
||||
params = {"response_type": "code", "client_id": cfg["oidc_client_id"],
|
||||
"redirect_uri": cfg["oidc_redirect_uri"], "scope": "openid profile email groups",
|
||||
"state": state, "nonce": nonce}
|
||||
auth_url = disco["authorization_endpoint"] + "?" + urlencode(params)
|
||||
return RedirectResponse(url=auth_url, status_code=302)
|
||||
|
||||
@app.get("/api/auth/oidc/callback")
|
||||
async def oidc_callback(request: Request, code: str = Query(...), state: str = Query(...)):
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
_check_rate_limit(ip)
|
||||
pending = _oidc_pop_state(state)
|
||||
if not pending:
|
||||
raise HTTPException(400, "OIDC: state inválido ou expirado")
|
||||
nonce = pending["nonce"]
|
||||
cfg = _get_oidc_config()
|
||||
disco = _oidc_discover(cfg["oidc_issuer"])
|
||||
import requests as req
|
||||
token_resp = req.post(disco["token_endpoint"], data={
|
||||
"grant_type": "authorization_code", "code": code,
|
||||
"redirect_uri": cfg["oidc_redirect_uri"],
|
||||
"client_id": cfg["oidc_client_id"], "client_secret": cfg["oidc_client_secret"],
|
||||
}, timeout=15)
|
||||
if token_resp.status_code != 200:
|
||||
log.error(f"OIDC token exchange failed: {token_resp.status_code} {token_resp.text[:500]}")
|
||||
raise HTTPException(502, "OIDC: falha ao trocar código por token")
|
||||
tokens = token_resp.json()
|
||||
id_token_raw = tokens.get("id_token")
|
||||
if not id_token_raw:
|
||||
raise HTTPException(502, "OIDC: id_token ausente na resposta")
|
||||
claims = _validate_id_token(id_token_raw, cfg["oidc_issuer"], cfg["oidc_client_id"], nonce)
|
||||
sub = claims.get("sub")
|
||||
email = claims.get("email", "")
|
||||
given_name = claims.get("given_name") or claims.get("name", "").split(" ")[0] or "OIDC"
|
||||
family_name = claims.get("family_name") or " ".join(claims.get("name", "User").split(" ")[1:]) or "User"
|
||||
groups = claims.get("groups", [])
|
||||
if not groups:
|
||||
groups = claims.get("http://www.oracle.com/groups", [])
|
||||
role = "viewer"
|
||||
if cfg.get("oidc_group_admin") and cfg["oidc_group_admin"] in groups:
|
||||
role = "admin"
|
||||
elif cfg.get("oidc_group_user") and cfg["oidc_group_user"] in groups:
|
||||
role = "user"
|
||||
with db() as c:
|
||||
u = c.execute("SELECT * FROM users WHERE oidc_subject=? AND is_active=1", (sub,)).fetchone()
|
||||
if u:
|
||||
u = dict(u)
|
||||
c.execute("UPDATE users SET role=?, last_login=datetime('now'), email=? WHERE id=?",
|
||||
(role, email or u.get("email"), u["id"]))
|
||||
u["role"] = role
|
||||
_audit(u["id"], u["username"], "oidc_login", details=f"sub={sub}", ip=ip)
|
||||
else:
|
||||
uid = str(uuid.uuid4())
|
||||
username = email.split("@")[0] if email else f"oidc_{sub[:8]}"
|
||||
base_username = username
|
||||
counter = 1
|
||||
while c.execute("SELECT 1 FROM users WHERE username=?", (username,)).fetchone():
|
||||
username = f"{base_username}_{counter}"
|
||||
counter += 1
|
||||
fake_hash = _hash_pw(secrets.token_hex(32))
|
||||
c.execute(
|
||||
"INSERT INTO users (id,first_name,last_name,username,email,password_hash,role,auth_provider,oidc_subject) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?)",
|
||||
(uid, given_name, family_name, username, email, fake_hash, role, "oidc", sub))
|
||||
u = {"id": uid, "first_name": given_name, "last_name": family_name,
|
||||
"username": username, "email": email, "role": role}
|
||||
_audit(uid, username, "oidc_jit_provision", details=f"sub={sub} email={email} role={role}", ip=ip)
|
||||
c.execute("UPDATE users SET last_login=datetime('now') WHERE id=?", (uid,))
|
||||
sid = str(uuid.uuid4())
|
||||
exp = (datetime.utcnow() + timedelta(hours=JWT_EXP_H)).isoformat()
|
||||
with db() as c:
|
||||
c.execute("INSERT INTO sessions (id,user_id,expires_at) VALUES (?,?,?)", (sid, u["id"], exp))
|
||||
token = _make_token(u["id"], u["role"], sid)
|
||||
html = f"""<!DOCTYPE html><html><head><title>OIDC Login</title></head><body>
|
||||
<script>localStorage.setItem('t',{json.dumps(token)});window.location.href='/chat';</script>
|
||||
<noscript><p>Login OK. <a href="/chat">Continuar</a></p></noscript></body></html>"""
|
||||
return HTMLResponse(content=html)
|
||||
|
||||
@app.post("/api/auth/oidc/logout")
|
||||
async def oidc_logout(u=Depends(current_user)):
|
||||
with db() as c:
|
||||
c.execute("UPDATE sessions SET is_active=0 WHERE user_id=?", (u["id"],))
|
||||
_audit(u["id"], u["username"], "oidc_logout")
|
||||
idp_logout_url = None
|
||||
try:
|
||||
cfg = _get_oidc_config()
|
||||
if cfg.get("oidc_issuer"):
|
||||
disco = _oidc_discover(cfg["oidc_issuer"])
|
||||
idp_logout_url = disco.get("end_session_endpoint")
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": True, "idp_logout_url": idp_logout_url}
|
||||
|
||||
@app.post("/api/settings/oidc/test")
|
||||
async def test_oidc(u=Depends(require("admin"))):
|
||||
cfg = _get_oidc_config()
|
||||
if not cfg.get("oidc_issuer"):
|
||||
raise HTTPException(400, "Issuer URL não configurado")
|
||||
import requests as req
|
||||
try:
|
||||
url = cfg["oidc_issuer"].rstrip("/") + "/.well-known/openid-configuration"
|
||||
resp = req.get(url, timeout=10)
|
||||
resp.raise_for_status()
|
||||
disco = resp.json()
|
||||
has_auth = bool(disco.get("authorization_endpoint"))
|
||||
has_token = bool(disco.get("token_endpoint"))
|
||||
has_jwks = bool(disco.get("jwks_uri"))
|
||||
return {"ok": has_auth and has_token and has_jwks, "issuer": disco.get("issuer"),
|
||||
"authorization_endpoint": has_auth, "token_endpoint": has_token,
|
||||
"jwks_uri": has_jwks, "end_session_endpoint": bool(disco.get("end_session_endpoint"))}
|
||||
except Exception as e:
|
||||
raise HTTPException(502, f"Falha ao conectar ao discovery: {str(e)}")
|
||||
|
||||
# ── MFA ───────────────────────────────────────────────────────────────────────
|
||||
@app.post("/api/mfa/setup")
|
||||
async def mfa_setup(u=Depends(current_user)):
|
||||
@@ -836,13 +1056,15 @@ async def mfa_disable(user_id: str, adm=Depends(require("admin"))):
|
||||
# ── Users ─────────────────────────────────────────────────────────────────────
|
||||
@app.get("/api/users")
|
||||
async def list_users(u=Depends(require("admin"))):
|
||||
with db() as c: rows = c.execute("SELECT id,first_name,last_name,username,email,role,mfa_enabled,timezone,is_active,created_at,last_login FROM users").fetchall()
|
||||
with db() as c: rows = c.execute("SELECT id,first_name,last_name,username,email,role,mfa_enabled,timezone,is_active,created_at,last_login,auth_provider FROM users").fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
@app.get("/api/users/me")
|
||||
async def me(u=Depends(current_user)):
|
||||
fields = ("id","first_name","last_name","username","email","role","mfa_enabled","timezone","auth_provider")
|
||||
return {k: u.get(k, "local" if k == "auth_provider" else None) for k in fields}
|
||||
fields = ("id","first_name","last_name","username","email","role","mfa_enabled","timezone","auth_provider","must_change_password")
|
||||
r = {k: u.get(k, "local" if k == "auth_provider" else None) for k in fields}
|
||||
r["must_change_password"] = bool(r.get("must_change_password", 0))
|
||||
return r
|
||||
|
||||
@app.put("/api/users/{uid}")
|
||||
async def update_user(uid: str, req: UserUpdateReq, adm=Depends(require("admin"))):
|
||||
@@ -9976,11 +10198,18 @@ async def get_setting(key: str, u=Depends(current_user)):
|
||||
raise HTTPException(403, "Acesso negado a configuração sensível")
|
||||
with db() as c:
|
||||
row = c.execute("SELECT value FROM app_settings WHERE key=?", (key,)).fetchone()
|
||||
return {"key": key, "value": row["value"] if row else ""}
|
||||
val = row["value"] if row else ""
|
||||
if key == "oidc_client_secret" and val:
|
||||
val = _mask(_safe_dec(val))
|
||||
return {"key": key, "value": val}
|
||||
|
||||
@app.put("/api/settings/{key}")
|
||||
async def put_setting(key: str, body: dict, u=Depends(require("admin"))):
|
||||
value = body.get("value", "")
|
||||
if key == "oidc_client_secret" and value and "\u2022" in value:
|
||||
return {"key": key, "value": value}
|
||||
if key == "oidc_client_secret" and value:
|
||||
value = _enc(value)
|
||||
with db() as c:
|
||||
c.execute("INSERT INTO app_settings (key,value,updated_at) VALUES (?,?,datetime('now')) ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at", (key, value))
|
||||
return {"key": key, "value": value}
|
||||
|
||||
@@ -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:-}
|
||||
|
||||
@@ -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 (
|
||||
<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() {
|
||||
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 (
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
{user && mustChangePassword && <ForcePasswordModal />}
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route element={<AppShell />}>
|
||||
|
||||
@@ -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<never, { ok: boolean; idp_logout_url?: string }>('/auth/oidc/logout'),
|
||||
|
||||
me: () => client.get<never, User>('/users/me'),
|
||||
|
||||
changePassword: (current_password: string, new_password: string) =>
|
||||
|
||||
@@ -45,6 +45,8 @@ const en: Record<string, string> = {
|
||||
'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',
|
||||
|
||||
@@ -45,6 +45,8 @@ const es: Record<string, string> = {
|
||||
'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',
|
||||
|
||||
@@ -45,6 +45,8 @@ const pt: Record<string, string> = {
|
||||
'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',
|
||||
|
||||
@@ -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() {
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--t3)' }}>{t('login.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||
{!mfaRequired ? (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('login.userPlaceholder')}
|
||||
value={username}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder={t('login.passPlaceholder')}
|
||||
value={password}
|
||||
onChange={(e) => 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 && (
|
||||
<div className="text-center mb-2">
|
||||
<p className="text-xs mb-2" style={{ color: 'var(--t3)' }}>
|
||||
{t('login.mfaScanQr')}
|
||||
</p>
|
||||
<img
|
||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(totpUri)}`}
|
||||
alt="QR Code"
|
||||
className="mx-auto rounded-lg"
|
||||
width={180}
|
||||
height={180}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('login.totpPlaceholder')}
|
||||
value={totp}
|
||||
onChange={(e) => 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' && (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||
{!mfaRequired ? (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('login.userPlaceholder')}
|
||||
value={username}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder={t('login.passPlaceholder')}
|
||||
value={password}
|
||||
onChange={(e) => 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 && (
|
||||
<div className="text-center mb-2">
|
||||
<p className="text-xs mb-2" style={{ color: 'var(--t3)' }}>
|
||||
{t('login.mfaScanQr')}
|
||||
</p>
|
||||
<img
|
||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(totpUri)}`}
|
||||
alt="QR Code"
|
||||
className="mx-auto rounded-lg"
|
||||
width={180}
|
||||
height={180}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('login.totpPlaceholder')}
|
||||
value={totp}
|
||||
onChange={(e) => 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 && (
|
||||
<div className="text-xs px-3 py-2 rounded-lg" style={{ background: 'var(--rdl)', color: 'var(--rd)' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{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 ? t('login.loading') : mfaRequired ? t('login.verify') : t('login.submit')}
|
||||
</button>
|
||||
</form>
|
||||
<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 ? t('login.loading') : mfaRequired ? t('login.verify') : t('login.submit')}
|
||||
</button>
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
<span className="text-xs" style={{ color: 'var(--t2)', fontFamily: 'var(--fm)' }}>
|
||||
{u.username}
|
||||
</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>
|
||||
|
||||
{/* Email */}
|
||||
|
||||
@@ -8,11 +8,13 @@ interface AuthState {
|
||||
mfaRequired: boolean;
|
||||
mfaSetup: boolean;
|
||||
totpUri: string | null;
|
||||
mustChangePassword: boolean;
|
||||
|
||||
login: (username: string, password: string, totp?: string) => Promise<LoginResponse>;
|
||||
logout: () => Promise<void>;
|
||||
checkAuth: () => Promise<void>;
|
||||
setToken: (token: string) => void;
|
||||
clearMustChangePassword: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
@@ -22,6 +24,7 @@ export const useAuthStore = create<AuthState>((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<AuthState>((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<AuthState>((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<AuthState>((set, get) => ({
|
||||
localStorage.setItem('t', token);
|
||||
set({ token });
|
||||
},
|
||||
|
||||
clearMustChangePassword: () => set({ mustChangePassword: false }),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user