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
|
# 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
|
||||||
|
|||||||
67
README.md
67
README.md
@@ -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. |
|
||||||
|
|||||||
253
backend/app.py
253
backend/app.py
@@ -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}
|
||||||
|
|||||||
@@ -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:-}
|
||||||
|
|||||||
@@ -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 />}>
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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 }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user