mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 06:40:45 +08:00
feat(auth): add local login, audit, API keys, and system admin UI
Gate netx Web/API/WebCRT with JWT and per-user API tokens, bootstrap an admin with forced password change, and expose users/audit/API-key management under a System section. MCP can reuse data/auth/mcp_token without extra env for local labs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
14f14d34bd
commit
6d4cd741ef
35 changed files with 2699 additions and 21 deletions
|
|
@ -37,3 +37,9 @@ NETX_UME_ALARM_WS_ENABLED=true
|
|||
NETX_UME_NOTIFICATION_ESTABLISH_PATH=/restconf/operations/zte-notifications:establish-subscription
|
||||
NETX_UME_NOTIFICATION_DELETE_PATH=/restconf/operations/zte-notifications:delete-subscription
|
||||
NETX_UME_NOTIFICATION_TOPIC=ALARM
|
||||
# Auth (optional — lab defaults: admin/admin123 + data/auth/mcp_token)
|
||||
# NETX_AUTH_ENABLED=true
|
||||
# NETX_AUTH_SECRET=change-me-in-production
|
||||
# NETX_BOOTSTRAP_ADMIN_USERNAME=admin
|
||||
# NETX_BOOTSTRAP_ADMIN_PASSWORD=admin123
|
||||
# NETX_API_TOKEN= # MCP: leave empty to auto-read data/auth/mcp_token
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -10,3 +10,4 @@ scripts/.run/
|
|||
ume/
|
||||
data/ne_collections/
|
||||
data/webcrt/
|
||||
data/auth/
|
||||
|
|
|
|||
31
README.md
31
README.md
|
|
@ -92,8 +92,39 @@ Option B: local `.env` (recommended)
|
|||
NETX_DATABASE_URL=postgresql+psycopg://postgres:admin123@127.0.0.1:5432/netx
|
||||
NETX_OCLAW_ANALYZE_TOKEN=admin123
|
||||
NETX_OCLAW_HEALTH_URL=http://127.0.0.1:8787/admin/api/ops-ai/health
|
||||
# App login (required for production)
|
||||
NETX_AUTH_ENABLED=true
|
||||
NETX_AUTH_SECRET=replace-with-a-long-random-string
|
||||
NETX_BOOTSTRAP_ADMIN_USERNAME=admin
|
||||
NETX_BOOTSTRAP_ADMIN_PASSWORD=change-me-on-first-boot
|
||||
```
|
||||
|
||||
### Auth & audit
|
||||
|
||||
**不必改 `.env` 也能用(本机默认):**
|
||||
|
||||
| 项 | 默认值 |
|
||||
|----|--------|
|
||||
| 登录账号 | `admin` / `admin123` |
|
||||
| `NETX_AUTH_SECRET` | 内置开发密钥(生产请改) |
|
||||
| MCP Token 文件 | 首次启动写入 `data/auth/mcp_token` |
|
||||
|
||||
生产建议在 `.env` 覆盖:
|
||||
|
||||
```env
|
||||
NETX_AUTH_SECRET=your-long-random-secret
|
||||
NETX_BOOTSTRAP_ADMIN_PASSWORD=your-strong-password
|
||||
```
|
||||
|
||||
- Web:打开 `/login`,用 `admin` / `admin123`(首次建库后生效)。工作台有 **API Key** 页可为不同用户生成 Token 并设置有效期。
|
||||
- MCP:优先读环境变量 `NETX_API_TOKEN`;未设置时自动读 `data/auth/mcp_token`(API 启动时生成)。也可在 Cursor MCP 配置里显式填写:
|
||||
|
||||
```json
|
||||
"NETX_API_TOKEN": "nxt_...."
|
||||
```
|
||||
|
||||
Token 内容见 `data/auth/mcp_token`,或登录后调用 `POST /v1/api-tokens` 新建。
|
||||
|
||||
### 5) Start services
|
||||
|
||||
Direct backend start:
|
||||
|
|
|
|||
12
docs/MCP.md
12
docs/MCP.md
|
|
@ -67,11 +67,16 @@ pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx
|
|||
| 变量 | 必填 | 默认 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `NETX_API_URL` | 否 | `http://127.0.0.1:8890` | netx REST 根地址,可指向远端 |
|
||||
| `NETX_API_TOKEN` | 否 | 空 | API 启用 Bearer 时填写 |
|
||||
| `NETX_API_TOKEN` | 否 | 空 | Bearer;空则自动读 `data/auth/mcp_token`(API 首次启动生成) |
|
||||
| `NETX_MCP_TOKEN_FILE` | 否 | `data/auth/mcp_token` | 默认 token 文件路径 |
|
||||
| `NETX_LANG` | 否 | `zh` | `zh` / `en`,影响 API 文案 |
|
||||
| `NETX_NE_EXEC_MAX_COMMANDS` | 否 | `5` | `execManagedNe` 单次最多命令数(硬上限 50);API 与 MCP 需同设 |
|
||||
|
||||
本机默认端口时 **可不设任何变量**。
|
||||
本机默认端口时 **可不设任何变量**。启用登录后,先启动一次 netx API,会生成 `data/auth/mcp_token`;MCP 会自动带上该 token。若要把 token 写进 Cursor 配置:
|
||||
|
||||
```json
|
||||
"NETX_API_TOKEN": "nxt_从文件复制的内容"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -87,7 +92,6 @@ pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx
|
|||
"args": ["-m", "netx_mcp"],
|
||||
"env": {
|
||||
"NETX_API_URL": "http://127.0.0.1:8890",
|
||||
"NETX_API_TOKEN": "",
|
||||
"NETX_LANG": "zh",
|
||||
"PYTHONIOENCODING": "utf-8",
|
||||
"PYTHONUTF8": "1"
|
||||
|
|
@ -97,6 +101,8 @@ pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx
|
|||
}
|
||||
```
|
||||
|
||||
(可选)显式设置 `"NETX_API_TOKEN": "nxt_..."`;不设则读仓库/`cwd` 下的 `data/auth/mcp_token`。
|
||||
|
||||
保存后 **重启 Cursor/客户端**,使 MCP 子进程重新拉起。
|
||||
|
||||
---
|
||||
|
|
|
|||
1
mcp.json
1
mcp.json
|
|
@ -5,7 +5,6 @@
|
|||
"args": ["-m", "netx_mcp"],
|
||||
"env": {
|
||||
"NETX_API_URL": "http://127.0.0.1:8890",
|
||||
"NETX_API_TOKEN": "",
|
||||
"NETX_LANG": "zh",
|
||||
"PYTHONIOENCODING": "utf-8",
|
||||
"PYTHONUTF8": "1"
|
||||
|
|
|
|||
101
netx_api/auth_deps.py
Normal file
101
netx_api/auth_deps.py
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
"""FastAPI dependencies for authenticated / admin-only routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .auth_service import get_user_by_id, resolve_api_token_user
|
||||
from .auth_tokens import decode_access_token
|
||||
from .config import settings
|
||||
from .db import get_db
|
||||
from .models import AppUser
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthContext:
|
||||
user: AppUser
|
||||
auth_via: str # jwt | api_token | disabled
|
||||
|
||||
|
||||
def _extract_bearer(request: Request) -> str:
|
||||
auth = str(request.headers.get("authorization") or "").strip()
|
||||
if auth.lower().startswith("bearer "):
|
||||
return auth[7:].strip()
|
||||
# WebCRT / tools may pass access_token query
|
||||
q = request.query_params.get("access_token")
|
||||
return str(q or "").strip()
|
||||
|
||||
|
||||
def resolve_user_from_token(db: Session, token: str) -> tuple[AppUser, str] | None:
|
||||
raw = str(token or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
if raw.startswith("nxt_"):
|
||||
user = resolve_api_token_user(db, raw)
|
||||
if user is None:
|
||||
return None
|
||||
return user, "api_token"
|
||||
try:
|
||||
payload = decode_access_token(raw)
|
||||
except Exception:
|
||||
return None
|
||||
if str(payload.get("typ") or "") not in ("", "access"):
|
||||
return None
|
||||
user = get_user_by_id(db, str(payload.get("sub") or ""))
|
||||
if user is None or not user.is_active:
|
||||
return None
|
||||
return user, "jwt"
|
||||
|
||||
|
||||
def get_optional_user(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> AuthContext | None:
|
||||
if not bool(settings.auth_enabled):
|
||||
return None
|
||||
token = _extract_bearer(request)
|
||||
if not token:
|
||||
# Middleware may have already attached user
|
||||
cached = getattr(request.state, "auth_user", None)
|
||||
if isinstance(cached, AppUser):
|
||||
via = str(getattr(request.state, "auth_via", "") or "jwt")
|
||||
return AuthContext(user=cached, auth_via=via)
|
||||
return None
|
||||
resolved = resolve_user_from_token(db, token)
|
||||
if resolved is None:
|
||||
return None
|
||||
user, via = resolved
|
||||
request.state.auth_user = user
|
||||
request.state.auth_via = via
|
||||
return AuthContext(user=user, auth_via=via)
|
||||
|
||||
|
||||
def require_user(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> AuthContext:
|
||||
if not bool(settings.auth_enabled):
|
||||
# Auth disabled: synthesize a system principal for Depends callers.
|
||||
fake = AppUser(
|
||||
id="system",
|
||||
username="system",
|
||||
password_hash="",
|
||||
role="admin",
|
||||
is_active=True,
|
||||
created_by="auth_disabled",
|
||||
)
|
||||
return AuthContext(user=fake, auth_via="disabled")
|
||||
ctx = get_optional_user(request, db)
|
||||
if ctx is None:
|
||||
raise HTTPException(status_code=401, detail="unauthorized")
|
||||
return ctx
|
||||
|
||||
|
||||
def require_admin(ctx: Annotated[AuthContext, Depends(require_user)]) -> AuthContext:
|
||||
if ctx.user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="admin_required")
|
||||
return ctx
|
||||
139
netx_api/auth_middleware.py
Normal file
139
netx_api/auth_middleware.py
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
"""HTTP auth gate + request audit middleware."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Callable
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, Response
|
||||
|
||||
from .auth_deps import resolve_user_from_token
|
||||
from .auth_service import write_audit
|
||||
from .config import settings
|
||||
from .db import SessionLocal
|
||||
|
||||
_log = logging.getLogger("netx.auth.mw")
|
||||
|
||||
_PUBLIC_EXACT = frozenset(
|
||||
{
|
||||
"/",
|
||||
"/health",
|
||||
"/openapi.json",
|
||||
"/docs",
|
||||
"/docs/oauth2-redirect",
|
||||
"/redoc",
|
||||
"/favicon.ico",
|
||||
"/v1/auth/login",
|
||||
}
|
||||
)
|
||||
_PUBLIC_PREFIXES = (
|
||||
"/docs",
|
||||
"/redoc",
|
||||
"/assets",
|
||||
)
|
||||
|
||||
|
||||
def _is_public(path: str) -> bool:
|
||||
p = str(path or "")
|
||||
if p in _PUBLIC_EXACT:
|
||||
return True
|
||||
return any(p.startswith(pref) for pref in _PUBLIC_PREFIXES)
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
return str(request.client.host if request.client else "")
|
||||
|
||||
|
||||
def _action_for(method: str, path: str) -> str:
|
||||
m = method.upper()
|
||||
p = path
|
||||
if p.startswith("/v1/auth/"):
|
||||
return f"auth.{p.rsplit('/', 1)[-1]}"
|
||||
if p.startswith("/v1/users"):
|
||||
return f"users.{m.lower()}"
|
||||
if p.startswith("/v1/audit-logs"):
|
||||
return "audit.list"
|
||||
if p.startswith("/v1/api-tokens"):
|
||||
return f"api_tokens.{m.lower()}"
|
||||
if p.startswith("/v1/webcrt"):
|
||||
return f"webcrt.{m.lower()}"
|
||||
if "/token" in p:
|
||||
return f"ume.token.{m.lower()}"
|
||||
return f"http.{m.lower()}"
|
||||
|
||||
|
||||
class AuthAuditMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||
if request.method.upper() == "OPTIONS":
|
||||
return await call_next(request)
|
||||
|
||||
path = request.url.path
|
||||
if not bool(settings.auth_enabled) or _is_public(path):
|
||||
return await call_next(request)
|
||||
|
||||
# WebSocket upgrades are authenticated inside the WS endpoint.
|
||||
if path.startswith("/v1/webcrt/") and path.endswith("/ws"):
|
||||
return await call_next(request)
|
||||
|
||||
token = ""
|
||||
auth = str(request.headers.get("authorization") or "").strip()
|
||||
if auth.lower().startswith("bearer "):
|
||||
token = auth[7:].strip()
|
||||
if not token:
|
||||
token = str(request.query_params.get("access_token") or "").strip()
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
resolved = resolve_user_from_token(db, token) if token else None
|
||||
if resolved is None:
|
||||
write_audit(
|
||||
db,
|
||||
action="auth.unauthorized",
|
||||
method=request.method,
|
||||
path=path,
|
||||
status_code=401,
|
||||
client_ip=_client_ip(request),
|
||||
user_agent=str(request.headers.get("user-agent") or "")[:512],
|
||||
detail={},
|
||||
)
|
||||
return JSONResponse(status_code=401, content={"detail": "unauthorized"})
|
||||
user, via = resolved
|
||||
request.state.auth_user = user
|
||||
request.state.auth_via = via
|
||||
actor_id = str(user.id)
|
||||
actor_name = str(user.username)
|
||||
auth_via = via
|
||||
except Exception:
|
||||
_log.exception("auth middleware failure path=%s", path)
|
||||
return JSONResponse(status_code=500, content={"detail": "auth_middleware_error"})
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
started = time.perf_counter()
|
||||
response = await call_next(request)
|
||||
try:
|
||||
db2 = SessionLocal()
|
||||
try:
|
||||
write_audit(
|
||||
db2,
|
||||
action=_action_for(request.method, path),
|
||||
actor_user_id=actor_id,
|
||||
actor_username=actor_name,
|
||||
method=request.method,
|
||||
path=path,
|
||||
status_code=int(response.status_code),
|
||||
client_ip=_client_ip(request),
|
||||
user_agent=str(request.headers.get("user-agent") or "")[:512],
|
||||
detail={
|
||||
"auth_via": auth_via,
|
||||
"elapsed_ms": int((time.perf_counter() - started) * 1000),
|
||||
},
|
||||
)
|
||||
finally:
|
||||
db2.close()
|
||||
except Exception:
|
||||
_log.exception("audit write after request failed path=%s", path)
|
||||
return response
|
||||
20
netx_api/auth_passwords.py
Normal file
20
netx_api/auth_passwords.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
"""Password hashing helpers (bcrypt)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import bcrypt
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
raw = str(password or "").encode("utf-8")
|
||||
return bcrypt.hashpw(raw, bcrypt.gensalt()).decode("ascii")
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
try:
|
||||
return bcrypt.checkpw(
|
||||
str(password or "").encode("utf-8"),
|
||||
str(password_hash or "").encode("ascii"),
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
315
netx_api/auth_router.py
Normal file
315
netx_api/auth_router.py
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
"""Auth, users, audit logs, and API token routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .auth_deps import AuthContext, require_admin, require_user
|
||||
from .auth_schemas import (
|
||||
ApiTokenCreateRequest,
|
||||
ChangePasswordRequest,
|
||||
LoginRequest,
|
||||
UserCreateRequest,
|
||||
UserUpdateRequest,
|
||||
)
|
||||
from .auth_service import (
|
||||
authenticate_user,
|
||||
change_password,
|
||||
create_api_token,
|
||||
create_user,
|
||||
list_api_tokens,
|
||||
list_audit_logs,
|
||||
list_users,
|
||||
login_issue_token,
|
||||
revoke_api_token,
|
||||
update_user,
|
||||
user_public,
|
||||
write_audit,
|
||||
)
|
||||
from .db import get_db
|
||||
|
||||
router = APIRouter(tags=["auth"])
|
||||
|
||||
|
||||
def _client_meta(request: Request) -> tuple[str, str]:
|
||||
ip = str(request.client.host if request.client else "")
|
||||
ua = str(request.headers.get("user-agent") or "")[:512]
|
||||
return ip, ua
|
||||
|
||||
|
||||
@router.post("/v1/auth/login")
|
||||
def api_login(body: LoginRequest, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||
ip, ua = _client_meta(request)
|
||||
user = authenticate_user(db, body.username, body.password)
|
||||
if user is None:
|
||||
write_audit(
|
||||
db,
|
||||
action="auth.login_failed",
|
||||
actor_username=str(body.username or "").strip(),
|
||||
method="POST",
|
||||
path="/v1/auth/login",
|
||||
status_code=401,
|
||||
client_ip=ip,
|
||||
user_agent=ua,
|
||||
detail={},
|
||||
)
|
||||
from fastapi import HTTPException
|
||||
|
||||
raise HTTPException(status_code=401, detail="invalid_credentials")
|
||||
out = login_issue_token(user)
|
||||
write_audit(
|
||||
db,
|
||||
action="auth.login",
|
||||
actor_user_id=user.id,
|
||||
actor_username=user.username,
|
||||
method="POST",
|
||||
path="/v1/auth/login",
|
||||
status_code=200,
|
||||
client_ip=ip,
|
||||
user_agent=ua,
|
||||
detail={"role": user.role},
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/v1/auth/logout")
|
||||
def api_logout(
|
||||
request: Request,
|
||||
ctx: Annotated[AuthContext, Depends(require_user)],
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, Any]:
|
||||
ip, ua = _client_meta(request)
|
||||
write_audit(
|
||||
db,
|
||||
action="auth.logout",
|
||||
actor_user_id=ctx.user.id,
|
||||
actor_username=ctx.user.username,
|
||||
method="POST",
|
||||
path="/v1/auth/logout",
|
||||
status_code=200,
|
||||
client_ip=ip,
|
||||
user_agent=ua,
|
||||
detail={"auth_via": ctx.auth_via},
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/v1/auth/me")
|
||||
def api_me(ctx: Annotated[AuthContext, Depends(require_user)]) -> dict[str, Any]:
|
||||
return {"user": user_public(ctx.user), "auth_via": ctx.auth_via}
|
||||
|
||||
|
||||
@router.post("/v1/auth/change-password")
|
||||
def api_change_password(
|
||||
body: ChangePasswordRequest,
|
||||
request: Request,
|
||||
ctx: Annotated[AuthContext, Depends(require_user)],
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, Any]:
|
||||
change_password(db, user=ctx.user, old_password=body.old_password, new_password=body.new_password)
|
||||
ip, ua = _client_meta(request)
|
||||
write_audit(
|
||||
db,
|
||||
action="auth.change_password",
|
||||
actor_user_id=ctx.user.id,
|
||||
actor_username=ctx.user.username,
|
||||
method="POST",
|
||||
path="/v1/auth/change-password",
|
||||
status_code=200,
|
||||
client_ip=ip,
|
||||
user_agent=ua,
|
||||
detail={},
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/v1/users")
|
||||
def api_list_users(ctx: Annotated[AuthContext, Depends(require_admin)], db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||
del ctx
|
||||
return {"items": list_users(db)}
|
||||
|
||||
|
||||
@router.post("/v1/users")
|
||||
def api_create_user(
|
||||
body: UserCreateRequest,
|
||||
request: Request,
|
||||
ctx: Annotated[AuthContext, Depends(require_admin)],
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, Any]:
|
||||
user = create_user(
|
||||
db,
|
||||
username=body.username,
|
||||
password=body.password,
|
||||
role=body.role,
|
||||
actor=ctx.user,
|
||||
)
|
||||
ip, ua = _client_meta(request)
|
||||
write_audit(
|
||||
db,
|
||||
action="users.create",
|
||||
actor_user_id=ctx.user.id,
|
||||
actor_username=ctx.user.username,
|
||||
method="POST",
|
||||
path="/v1/users",
|
||||
status_code=200,
|
||||
client_ip=ip,
|
||||
user_agent=ua,
|
||||
detail={"target_username": user.username, "role": user.role},
|
||||
)
|
||||
return {"user": user_public(user)}
|
||||
|
||||
|
||||
@router.patch("/v1/users/{user_id}")
|
||||
def api_update_user(
|
||||
user_id: str,
|
||||
body: UserUpdateRequest,
|
||||
request: Request,
|
||||
ctx: Annotated[AuthContext, Depends(require_admin)],
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, Any]:
|
||||
user = update_user(
|
||||
db,
|
||||
user_id=user_id,
|
||||
actor=ctx.user,
|
||||
is_active=body.is_active,
|
||||
role=body.role,
|
||||
password=body.password,
|
||||
)
|
||||
ip, ua = _client_meta(request)
|
||||
write_audit(
|
||||
db,
|
||||
action="users.update",
|
||||
actor_user_id=ctx.user.id,
|
||||
actor_username=ctx.user.username,
|
||||
method="PATCH",
|
||||
path=f"/v1/users/{user_id}",
|
||||
status_code=200,
|
||||
client_ip=ip,
|
||||
user_agent=ua,
|
||||
detail={
|
||||
"target_username": user.username,
|
||||
"is_active": user.is_active,
|
||||
"role": user.role,
|
||||
"password_reset": body.password is not None,
|
||||
},
|
||||
)
|
||||
return {"user": user_public(user)}
|
||||
|
||||
|
||||
@router.get("/v1/audit-logs")
|
||||
def api_audit_logs(
|
||||
ctx: Annotated[AuthContext, Depends(require_user)],
|
||||
db: Session = Depends(get_db),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=50, ge=1, le=200),
|
||||
username: str = Query(default=""),
|
||||
action: str = Query(default=""),
|
||||
) -> dict[str, Any]:
|
||||
return list_audit_logs(
|
||||
db,
|
||||
actor=ctx.user,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
username=username,
|
||||
action=action,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/v1/api-tokens")
|
||||
def api_list_tokens(
|
||||
ctx: Annotated[AuthContext, Depends(require_user)],
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, Any]:
|
||||
user_id = None if ctx.user.role == "admin" else ctx.user.id
|
||||
return {"items": list_api_tokens(db, user_id=user_id)}
|
||||
|
||||
|
||||
@router.post("/v1/api-tokens")
|
||||
def api_create_token(
|
||||
body: ApiTokenCreateRequest,
|
||||
request: Request,
|
||||
ctx: Annotated[AuthContext, Depends(require_user)],
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, Any]:
|
||||
from .auth_service import get_user_by_id
|
||||
|
||||
target = ctx.user
|
||||
target_user_id = str(body.user_id or "").strip()
|
||||
if target_user_id and target_user_id != ctx.user.id:
|
||||
if ctx.user.role != "admin":
|
||||
from fastapi import HTTPException
|
||||
|
||||
raise HTTPException(status_code=403, detail="admin_required")
|
||||
other = get_user_by_id(db, target_user_id)
|
||||
if other is None or not other.is_active:
|
||||
from fastapi import HTTPException
|
||||
|
||||
raise HTTPException(status_code=404, detail="user_not_found")
|
||||
target = other
|
||||
|
||||
expires_in_days = body.expires_in_days
|
||||
if expires_in_days is None:
|
||||
expires_in_days = 90
|
||||
row, plaintext = create_api_token(
|
||||
db,
|
||||
user=target,
|
||||
name=body.name,
|
||||
expires_in_days=expires_in_days,
|
||||
)
|
||||
ip, ua = _client_meta(request)
|
||||
write_audit(
|
||||
db,
|
||||
action="api_tokens.create",
|
||||
actor_user_id=ctx.user.id,
|
||||
actor_username=ctx.user.username,
|
||||
method="POST",
|
||||
path="/v1/api-tokens",
|
||||
status_code=200,
|
||||
client_ip=ip,
|
||||
user_agent=ua,
|
||||
detail={
|
||||
"token_id": row.id,
|
||||
"name": row.name,
|
||||
"owner_user_id": target.id,
|
||||
"owner_username": target.username,
|
||||
"expires_at": row.expires_at.isoformat() if row.expires_at else None,
|
||||
},
|
||||
)
|
||||
return {
|
||||
"token": {
|
||||
"id": row.id,
|
||||
"name": row.name,
|
||||
"user_id": row.user_id,
|
||||
"username": target.username,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"expires_at": row.expires_at.isoformat() if row.expires_at else None,
|
||||
"token": plaintext,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/v1/api-tokens/{token_id}")
|
||||
def api_revoke_token(
|
||||
token_id: str,
|
||||
request: Request,
|
||||
ctx: Annotated[AuthContext, Depends(require_user)],
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, Any]:
|
||||
row = revoke_api_token(db, token_id=token_id, actor=ctx.user)
|
||||
ip, ua = _client_meta(request)
|
||||
write_audit(
|
||||
db,
|
||||
action="api_tokens.revoke",
|
||||
actor_user_id=ctx.user.id,
|
||||
actor_username=ctx.user.username,
|
||||
method="DELETE",
|
||||
path=f"/v1/api-tokens/{token_id}",
|
||||
status_code=200,
|
||||
client_ip=ip,
|
||||
user_agent=ua,
|
||||
detail={"token_id": row.id, "name": row.name},
|
||||
)
|
||||
return {"ok": True, "id": row.id}
|
||||
35
netx_api/auth_schemas.py
Normal file
35
netx_api/auth_schemas.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
"""Pydantic schemas for auth / users / audit / API tokens."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str = Field(min_length=1, max_length=64)
|
||||
password: str = Field(min_length=1, max_length=256)
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
old_password: str = Field(min_length=1, max_length=256)
|
||||
new_password: str = Field(min_length=6, max_length=256)
|
||||
|
||||
|
||||
class UserCreateRequest(BaseModel):
|
||||
username: str = Field(min_length=2, max_length=64)
|
||||
password: str = Field(min_length=6, max_length=256)
|
||||
role: str = Field(default="user")
|
||||
|
||||
|
||||
class UserUpdateRequest(BaseModel):
|
||||
is_active: bool | None = None
|
||||
role: str | None = None
|
||||
password: str | None = Field(default=None, min_length=6, max_length=256)
|
||||
|
||||
|
||||
class ApiTokenCreateRequest(BaseModel):
|
||||
name: str = Field(default="default", max_length=128)
|
||||
# Days until expiry; 0 / null = never expires.
|
||||
expires_in_days: int | None = Field(default=90, ge=0, le=3650)
|
||||
# Admin may create a token for another user; others ignored / forced to self.
|
||||
user_id: str | None = None
|
||||
463
netx_api/auth_service.py
Normal file
463
netx_api/auth_service.py
Normal file
|
|
@ -0,0 +1,463 @@
|
|||
"""Auth domain service: bootstrap admin, users, API tokens, audit writes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .auth_passwords import hash_password, verify_password
|
||||
from .auth_tokens import hash_api_token, issue_access_token, new_api_token_plaintext
|
||||
from .config import settings
|
||||
from .models import ApiToken, AppUser, AuditLog
|
||||
|
||||
_log = logging.getLogger("netx.auth")
|
||||
|
||||
_USERNAME_RE = re.compile(r"^[A-Za-z0-9._@-]{2,64}$")
|
||||
_SECRET_KEYS = frozenset(
|
||||
{
|
||||
"password",
|
||||
"password_hash",
|
||||
"hop_password",
|
||||
"enable_secret",
|
||||
"access_token",
|
||||
"token",
|
||||
"authorization",
|
||||
"secret",
|
||||
"credential_secret_key",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def user_public(user: AppUser) -> dict[str, Any]:
|
||||
return {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"role": user.role,
|
||||
"is_active": bool(user.is_active),
|
||||
"must_change_password": bool(getattr(user, "must_change_password", False)),
|
||||
"created_by": user.created_by or "",
|
||||
"created_at": user.created_at.isoformat() if user.created_at else None,
|
||||
"updated_at": user.updated_at.isoformat() if user.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
def sanitize_detail(detail: Any) -> Any:
|
||||
"""Recursively drop secret-looking keys from audit detail payloads."""
|
||||
if isinstance(detail, dict):
|
||||
out: dict[str, Any] = {}
|
||||
for k, v in detail.items():
|
||||
key = str(k).lower()
|
||||
if key in _SECRET_KEYS or key.endswith("_password") or key.endswith("_secret"):
|
||||
out[k] = "***"
|
||||
else:
|
||||
out[k] = sanitize_detail(v)
|
||||
return out
|
||||
if isinstance(detail, list):
|
||||
return [sanitize_detail(x) for x in detail[:50]]
|
||||
if isinstance(detail, str) and len(detail) > 2000:
|
||||
return detail[:2000] + "…"
|
||||
return detail
|
||||
|
||||
|
||||
def write_audit(
|
||||
db: Session,
|
||||
*,
|
||||
action: str,
|
||||
actor_user_id: str = "",
|
||||
actor_username: str = "",
|
||||
method: str = "",
|
||||
path: str = "",
|
||||
status_code: int = 0,
|
||||
client_ip: str = "",
|
||||
user_agent: str = "",
|
||||
detail: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
row = AuditLog(
|
||||
actor_user_id=str(actor_user_id or ""),
|
||||
actor_username=str(actor_username or ""),
|
||||
action=str(action or "")[:128],
|
||||
method=str(method or "")[:16],
|
||||
path=str(path or "")[:512],
|
||||
status_code=int(status_code or 0),
|
||||
client_ip=str(client_ip or "")[:128],
|
||||
user_agent=str(user_agent or "")[:512],
|
||||
detail=sanitize_detail(detail or {}),
|
||||
)
|
||||
db.add(row)
|
||||
try:
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
_log.exception("audit_log write failed action=%s", action)
|
||||
|
||||
|
||||
def flag_default_password_users(db: Session) -> None:
|
||||
"""Mark accounts still on the bootstrap default password as must_change_password."""
|
||||
default_pwd = str(settings.bootstrap_admin_password or "admin123").strip() or "admin123"
|
||||
changed = 0
|
||||
for user in db.query(AppUser).filter(AppUser.is_active.is_(True)).all():
|
||||
if bool(getattr(user, "must_change_password", False)):
|
||||
continue
|
||||
if verify_password(default_pwd, user.password_hash):
|
||||
user.must_change_password = True
|
||||
user.updated_at = datetime.utcnow()
|
||||
changed += 1
|
||||
if changed:
|
||||
db.commit()
|
||||
_log.warning("flagged %s user(s) still using default password to must_change_password", changed)
|
||||
|
||||
|
||||
def bootstrap_admin_if_needed(db: Session) -> None:
|
||||
"""Create the first admin when app_user is empty."""
|
||||
count = int(db.query(func.count(AppUser.id)).scalar() or 0)
|
||||
if count > 0:
|
||||
flag_default_password_users(db)
|
||||
ensure_default_mcp_token(db)
|
||||
return
|
||||
username = str(settings.bootstrap_admin_username or "admin").strip() or "admin"
|
||||
password = str(settings.bootstrap_admin_password or "admin123").strip() or "admin123"
|
||||
if password == "admin123":
|
||||
_log.warning(
|
||||
"bootstrapping admin %r with default password admin123; change after first login",
|
||||
username,
|
||||
)
|
||||
if not _USERNAME_RE.match(username):
|
||||
raise RuntimeError(f"invalid_bootstrap_admin_username:{username}")
|
||||
user = AppUser(
|
||||
username=username,
|
||||
password_hash=hash_password(password),
|
||||
role="admin",
|
||||
is_active=True,
|
||||
must_change_password=True,
|
||||
created_by="bootstrap",
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
write_audit(
|
||||
db,
|
||||
action="auth.bootstrap_admin",
|
||||
actor_user_id=user.id,
|
||||
actor_username=user.username,
|
||||
detail={"username": username, "must_change_password": True},
|
||||
)
|
||||
_log.info("bootstrapped admin user %r id=%s", username, user.id)
|
||||
ensure_default_mcp_token(db, user=user)
|
||||
|
||||
|
||||
def mcp_token_file_path() -> Path:
|
||||
raw = str(settings.auth_mcp_token_file or "data/auth/mcp_token").strip()
|
||||
path = Path(raw)
|
||||
if not path.is_absolute():
|
||||
path = Path.cwd() / path
|
||||
return path
|
||||
|
||||
|
||||
def ensure_default_mcp_token(db: Session, user: AppUser | None = None) -> str | None:
|
||||
"""Ensure a default API token file exists for MCP (lab convenience).
|
||||
|
||||
Returns plaintext token when created or when file already present; None on failure.
|
||||
"""
|
||||
path = mcp_token_file_path()
|
||||
try:
|
||||
if path.is_file():
|
||||
existing = path.read_text(encoding="utf-8").strip()
|
||||
if existing.startswith("nxt_"):
|
||||
# Keep DB in sync if token was wiped from DB but file remains.
|
||||
th = hash_api_token(existing)
|
||||
row = (
|
||||
db.query(ApiToken)
|
||||
.filter(ApiToken.token_hash == th, ApiToken.revoked_at.is_(None))
|
||||
.one_or_none()
|
||||
)
|
||||
if row is not None:
|
||||
return existing
|
||||
except Exception:
|
||||
_log.exception("read mcp token file failed path=%s", path)
|
||||
|
||||
admin = user
|
||||
if admin is None:
|
||||
admin = (
|
||||
db.query(AppUser)
|
||||
.filter(AppUser.role == "admin", AppUser.is_active.is_(True))
|
||||
.order_by(AppUser.created_at.asc())
|
||||
.first()
|
||||
)
|
||||
if admin is None:
|
||||
return None
|
||||
try:
|
||||
row, plaintext = create_api_token(db, user=admin, name="mcp-default", expires_in_days=0)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(plaintext + "\n", encoding="utf-8")
|
||||
try:
|
||||
path.chmod(0o600)
|
||||
except Exception:
|
||||
pass
|
||||
write_audit(
|
||||
db,
|
||||
action="api_tokens.bootstrap_mcp",
|
||||
actor_user_id=admin.id,
|
||||
actor_username=admin.username,
|
||||
detail={"token_id": row.id, "name": row.name, "file": str(path)},
|
||||
)
|
||||
_log.info("wrote default MCP API token to %s", path)
|
||||
return plaintext
|
||||
except Exception:
|
||||
_log.exception("ensure_default_mcp_token failed")
|
||||
return None
|
||||
|
||||
|
||||
def get_user_by_id(db: Session, user_id: str) -> AppUser | None:
|
||||
return db.query(AppUser).filter(AppUser.id == str(user_id or "")).one_or_none()
|
||||
|
||||
|
||||
def get_user_by_username(db: Session, username: str) -> AppUser | None:
|
||||
return db.query(AppUser).filter(AppUser.username == str(username or "").strip()).one_or_none()
|
||||
|
||||
|
||||
def authenticate_user(db: Session, username: str, password: str) -> AppUser | None:
|
||||
user = get_user_by_username(db, username)
|
||||
if user is None or not user.is_active:
|
||||
return None
|
||||
if not verify_password(password, user.password_hash):
|
||||
return None
|
||||
return user
|
||||
|
||||
|
||||
def login_issue_token(user: AppUser) -> dict[str, Any]:
|
||||
token = issue_access_token(user_id=user.id, username=user.username, role=user.role)
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
"user": user_public(user),
|
||||
}
|
||||
|
||||
|
||||
def list_users(db: Session) -> list[dict[str, Any]]:
|
||||
rows = db.query(AppUser).order_by(AppUser.created_at.asc()).all()
|
||||
return [user_public(u) for u in rows]
|
||||
|
||||
|
||||
def create_user(
|
||||
db: Session,
|
||||
*,
|
||||
username: str,
|
||||
password: str,
|
||||
role: str,
|
||||
actor: AppUser,
|
||||
) -> AppUser:
|
||||
name = str(username or "").strip()
|
||||
if not _USERNAME_RE.match(name):
|
||||
raise HTTPException(status_code=400, detail="invalid_username")
|
||||
pwd = str(password or "")
|
||||
if len(pwd) < 6:
|
||||
raise HTTPException(status_code=400, detail="password_too_short")
|
||||
role_n = str(role or "user").strip().lower()
|
||||
if role_n not in ("admin", "user"):
|
||||
raise HTTPException(status_code=400, detail="invalid_role")
|
||||
if get_user_by_username(db, name) is not None:
|
||||
raise HTTPException(status_code=409, detail="username_exists")
|
||||
user = AppUser(
|
||||
username=name,
|
||||
password_hash=hash_password(pwd),
|
||||
role=role_n,
|
||||
is_active=True,
|
||||
created_by=actor.id,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
def update_user(
|
||||
db: Session,
|
||||
*,
|
||||
user_id: str,
|
||||
actor: AppUser,
|
||||
is_active: bool | None = None,
|
||||
role: str | None = None,
|
||||
password: str | None = None,
|
||||
) -> AppUser:
|
||||
user = get_user_by_id(db, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="user_not_found")
|
||||
if user.id == actor.id and is_active is False:
|
||||
raise HTTPException(status_code=400, detail="cannot_deactivate_self")
|
||||
if role is not None:
|
||||
role_n = str(role).strip().lower()
|
||||
if role_n not in ("admin", "user"):
|
||||
raise HTTPException(status_code=400, detail="invalid_role")
|
||||
if user.id == actor.id and role_n != "admin":
|
||||
raise HTTPException(status_code=400, detail="cannot_demote_self")
|
||||
user.role = role_n
|
||||
if is_active is not None:
|
||||
user.is_active = bool(is_active)
|
||||
if password is not None:
|
||||
pwd = str(password)
|
||||
if len(pwd) < 6:
|
||||
raise HTTPException(status_code=400, detail="password_too_short")
|
||||
user.password_hash = hash_password(pwd)
|
||||
user.must_change_password = True
|
||||
user.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
def change_password(db: Session, *, user: AppUser, old_password: str, new_password: str) -> None:
|
||||
if not verify_password(old_password, user.password_hash):
|
||||
raise HTTPException(status_code=400, detail="old_password_incorrect")
|
||||
pwd = str(new_password or "")
|
||||
if len(pwd) < 6:
|
||||
raise HTTPException(status_code=400, detail="password_too_short")
|
||||
default_pwd = str(settings.bootstrap_admin_password or "admin123").strip() or "admin123"
|
||||
if pwd == default_pwd or pwd == old_password:
|
||||
raise HTTPException(status_code=400, detail="password_must_differ_from_default")
|
||||
user.password_hash = hash_password(pwd)
|
||||
user.must_change_password = False
|
||||
user.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
|
||||
def create_api_token(
|
||||
db: Session,
|
||||
*,
|
||||
user: AppUser,
|
||||
name: str,
|
||||
expires_in_days: int | None = None,
|
||||
) -> tuple[ApiToken, str]:
|
||||
label = str(name or "").strip() or "default"
|
||||
if len(label) > 128:
|
||||
raise HTTPException(status_code=400, detail="token_name_too_long")
|
||||
expires_at: datetime | None = None
|
||||
if expires_in_days is not None and int(expires_in_days) > 0:
|
||||
expires_at = datetime.utcnow() + timedelta(days=int(expires_in_days))
|
||||
plaintext = new_api_token_plaintext()
|
||||
row = ApiToken(
|
||||
name=label,
|
||||
token_hash=hash_api_token(plaintext),
|
||||
user_id=user.id,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row, plaintext
|
||||
|
||||
|
||||
def _token_public(db: Session, r: ApiToken) -> dict[str, Any]:
|
||||
owner = get_user_by_id(db, r.user_id)
|
||||
now = datetime.utcnow()
|
||||
expired = bool(r.expires_at and r.expires_at <= now)
|
||||
return {
|
||||
"id": r.id,
|
||||
"name": r.name,
|
||||
"user_id": r.user_id,
|
||||
"username": owner.username if owner else "",
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
"expires_at": r.expires_at.isoformat() if r.expires_at else None,
|
||||
"last_used_at": r.last_used_at.isoformat() if r.last_used_at else None,
|
||||
"revoked_at": r.revoked_at.isoformat() if r.revoked_at else None,
|
||||
"revoked": bool(r.revoked_at),
|
||||
"expired": expired,
|
||||
"active": (not bool(r.revoked_at)) and (not expired),
|
||||
}
|
||||
|
||||
|
||||
def list_api_tokens(db: Session, *, user_id: str | None = None) -> list[dict[str, Any]]:
|
||||
q = db.query(ApiToken)
|
||||
if user_id:
|
||||
q = q.filter(ApiToken.user_id == user_id)
|
||||
rows = q.order_by(ApiToken.created_at.desc()).all()
|
||||
return [_token_public(db, r) for r in rows]
|
||||
|
||||
|
||||
def revoke_api_token(db: Session, *, token_id: str, actor: AppUser) -> ApiToken:
|
||||
row = db.query(ApiToken).filter(ApiToken.id == str(token_id)).one_or_none()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="api_token_not_found")
|
||||
if actor.role != "admin" and row.user_id != actor.id:
|
||||
raise HTTPException(status_code=403, detail="forbidden")
|
||||
if row.revoked_at is None:
|
||||
row.revoked_at = datetime.utcnow()
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
def resolve_api_token_user(db: Session, plaintext: str) -> AppUser | None:
|
||||
th = hash_api_token(plaintext)
|
||||
row = (
|
||||
db.query(ApiToken)
|
||||
.filter(ApiToken.token_hash == th, ApiToken.revoked_at.is_(None))
|
||||
.one_or_none()
|
||||
)
|
||||
if row is None:
|
||||
return None
|
||||
if row.expires_at is not None and row.expires_at <= datetime.utcnow():
|
||||
return None
|
||||
user = get_user_by_id(db, row.user_id)
|
||||
if user is None or not user.is_active:
|
||||
return None
|
||||
row.last_used_at = datetime.utcnow()
|
||||
try:
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
return user
|
||||
|
||||
|
||||
def list_audit_logs(
|
||||
db: Session,
|
||||
*,
|
||||
actor: AppUser,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
username: str = "",
|
||||
action: str = "",
|
||||
) -> dict[str, Any]:
|
||||
page = max(1, int(page or 1))
|
||||
page_size = max(1, min(200, int(page_size or 50)))
|
||||
q = db.query(AuditLog)
|
||||
if actor.role != "admin":
|
||||
q = q.filter(AuditLog.actor_user_id == actor.id)
|
||||
elif username.strip():
|
||||
q = q.filter(AuditLog.actor_username == username.strip())
|
||||
if action.strip():
|
||||
q = q.filter(AuditLog.action.ilike(f"%{action.strip()}%"))
|
||||
total = int(q.count())
|
||||
rows = (
|
||||
q.order_by(AuditLog.ts.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
items = [
|
||||
{
|
||||
"id": r.id,
|
||||
"ts": r.ts.isoformat() if r.ts else None,
|
||||
"actor_user_id": r.actor_user_id,
|
||||
"actor_username": r.actor_username,
|
||||
"action": r.action,
|
||||
"method": r.method,
|
||||
"path": r.path,
|
||||
"status_code": r.status_code,
|
||||
"client_ip": r.client_ip,
|
||||
"user_agent": r.user_agent,
|
||||
"detail": r.detail or {},
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
return {
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"items": items,
|
||||
}
|
||||
62
netx_api/auth_tokens.py
Normal file
62
netx_api/auth_tokens.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
"""JWT access tokens and opaque API token hashing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
|
||||
from .config import settings
|
||||
|
||||
_log = logging.getLogger("netx.auth")
|
||||
|
||||
_DEFAULT_DEV_SECRET = "netx-dev-auth-secret-change-me-in-production-32b"
|
||||
_warned_default_secret = False
|
||||
|
||||
|
||||
def auth_secret() -> str:
|
||||
"""Return configured secret (lab default is set in Settings)."""
|
||||
global _warned_default_secret
|
||||
configured = str(settings.auth_secret or "").strip() or _DEFAULT_DEV_SECRET
|
||||
if configured == _DEFAULT_DEV_SECRET and not _warned_default_secret:
|
||||
_warned_default_secret = True
|
||||
_log.warning(
|
||||
"using default NETX_AUTH_SECRET; set a unique secret for production deployments"
|
||||
)
|
||||
return configured
|
||||
|
||||
|
||||
def issue_access_token(*, user_id: str, username: str, role: str) -> str:
|
||||
ttl = max(300, int(settings.auth_token_ttl_sec or 86400))
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"sub": str(user_id),
|
||||
"username": str(username),
|
||||
"role": str(role),
|
||||
"typ": "access",
|
||||
"iat": int(now.timestamp()),
|
||||
"exp": int((now + timedelta(seconds=ttl)).timestamp()),
|
||||
}
|
||||
return jwt.encode(payload, auth_secret(), algorithm="HS256")
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> dict[str, Any]:
|
||||
return jwt.decode(
|
||||
str(token or ""),
|
||||
auth_secret(),
|
||||
algorithms=["HS256"],
|
||||
options={"require": ["exp", "sub"]},
|
||||
)
|
||||
|
||||
|
||||
def new_api_token_plaintext() -> str:
|
||||
"""Generate opaque API token (shown once). Prefix helps ops identify netx tokens."""
|
||||
return "nxt_" + secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def hash_api_token(plaintext: str) -> str:
|
||||
return hashlib.sha256(str(plaintext or "").encode("utf-8")).hexdigest()
|
||||
|
|
@ -80,6 +80,15 @@ class Settings(BaseSettings):
|
|||
webcrt_connect_timeout_sec: int = 90
|
||||
webcrt_attach_timeout_sec: int = 60
|
||||
webcrt_data_dir: str = "data/webcrt"
|
||||
# Local app login / audit (lab defaults; override in production)
|
||||
auth_enabled: bool = True
|
||||
# Stable default so JWT survives restarts without .env. Override in production.
|
||||
auth_secret: str = "netx-dev-auth-secret-change-me-in-production-32b"
|
||||
auth_token_ttl_sec: int = 86400
|
||||
bootstrap_admin_username: str = "admin"
|
||||
bootstrap_admin_password: str = "admin123"
|
||||
# Written on first boot for MCP; path relative to cwd / absolute
|
||||
auth_mcp_token_file: str = "data/auth/mcp_token"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@ from typing import Any
|
|||
import uvicorn
|
||||
|
||||
from .ap_client import analyze_with_oclaw, health_with_oclaw
|
||||
from .auth_middleware import AuthAuditMiddleware
|
||||
from .auth_router import router as auth_router
|
||||
from .auth_service import bootstrap_admin_if_needed
|
||||
from .config import settings
|
||||
from .db import Base, SessionLocal, engine, get_db
|
||||
from .collection_router import router as collection_router
|
||||
|
|
@ -31,6 +34,9 @@ from .models import (
|
|||
AiAnalyzeHistory,
|
||||
AlarmBatch,
|
||||
AlarmNorm,
|
||||
ApiToken,
|
||||
AppUser,
|
||||
AuditLog,
|
||||
ImportErrorRow,
|
||||
ManagedNE,
|
||||
NeCollectionJob,
|
||||
|
|
@ -122,6 +128,8 @@ from .schemas import (
|
|||
)
|
||||
|
||||
app = FastAPI(title="netx ops tool", version="0.1.0")
|
||||
app.add_middleware(AuthAuditMiddleware)
|
||||
app.include_router(auth_router)
|
||||
app.include_router(managed_ne_router)
|
||||
app.include_router(cli_router)
|
||||
app.include_router(collection_router)
|
||||
|
|
@ -794,6 +802,15 @@ def on_startup() -> None:
|
|||
_configure_ume_diag_logging()
|
||||
Base.metadata.create_all(bind=engine)
|
||||
_migrate_key_alert_rule_schema()
|
||||
# Auth columns must exist before bootstrap / flag_default_password_users.
|
||||
try:
|
||||
with engine.begin() as conn:
|
||||
conn.exec_driver_sql(
|
||||
"ALTER TABLE app_user ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN DEFAULT FALSE"
|
||||
)
|
||||
conn.exec_driver_sql("ALTER TABLE api_token ADD COLUMN IF NOT EXISTS expires_at TIMESTAMP")
|
||||
except Exception:
|
||||
_schedule_log.exception("startup: auth schema migration failed")
|
||||
_reset_runtime_pause_flags()
|
||||
_fail_stale_running_sync_jobs_on_startup()
|
||||
if _needs_startup_alarm_sync_before_ws():
|
||||
|
|
@ -806,6 +823,10 @@ def on_startup() -> None:
|
|||
complete_startup_alarm_sync_gate()
|
||||
db = SessionLocal()
|
||||
try:
|
||||
try:
|
||||
bootstrap_admin_if_needed(db)
|
||||
except Exception:
|
||||
_schedule_log.exception("startup: auth bootstrap admin failed")
|
||||
from .collection_recovery import recover_collection_jobs_on_startup
|
||||
|
||||
resumed = recover_collection_jobs_on_startup(db)
|
||||
|
|
@ -899,6 +920,10 @@ def on_startup() -> None:
|
|||
conn.exec_driver_sql("ALTER TABLE ume_alarms_current DROP COLUMN IF EXISTS user_label")
|
||||
conn.exec_driver_sql("ALTER TABLE ume_alarms_history DROP COLUMN IF EXISTS ne_name")
|
||||
conn.exec_driver_sql("ALTER TABLE ume_alarms_history DROP COLUMN IF EXISTS user_label")
|
||||
conn.exec_driver_sql("ALTER TABLE api_token ADD COLUMN IF NOT EXISTS expires_at TIMESTAMP")
|
||||
conn.exec_driver_sql(
|
||||
"ALTER TABLE app_user ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN DEFAULT FALSE"
|
||||
)
|
||||
conn.exec_driver_sql("ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_enabled BOOLEAN DEFAULT FALSE")
|
||||
conn.exec_driver_sql("ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_vendor VARCHAR(32) DEFAULT 'zte'")
|
||||
conn.exec_driver_sql("ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_host VARCHAR(128) DEFAULT ''")
|
||||
|
|
|
|||
|
|
@ -3,11 +3,16 @@ from __future__ import annotations
|
|||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.types import JSON
|
||||
|
||||
from .db import Base
|
||||
|
||||
# JSONB on Postgres; plain JSON elsewhere (unit tests / sqlite).
|
||||
_JsonType = JSON().with_variant(JSONB(), "postgresql")
|
||||
|
||||
|
||||
class AlarmBatch(Base):
|
||||
__tablename__ = "alarm_batches"
|
||||
|
|
@ -430,3 +435,52 @@ class TopologyEdge(Base):
|
|||
discovered_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class AppUser(Base):
|
||||
"""Local netx application user (login account)."""
|
||||
|
||||
__tablename__ = "app_user"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
|
||||
username: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), default="")
|
||||
role: Mapped[str] = mapped_column(String(32), default="user", index=True) # admin | user
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
||||
must_change_password: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_by: Mapped[str] = mapped_column(String(64), default="")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
"""Application audit trail for authenticated (and auth) actions."""
|
||||
|
||||
__tablename__ = "audit_log"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
|
||||
ts: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||
actor_user_id: Mapped[str] = mapped_column(String(64), default="", index=True)
|
||||
actor_username: Mapped[str] = mapped_column(String(128), default="", index=True)
|
||||
action: Mapped[str] = mapped_column(String(128), default="", index=True)
|
||||
method: Mapped[str] = mapped_column(String(16), default="")
|
||||
path: Mapped[str] = mapped_column(String(512), default="", index=True)
|
||||
status_code: Mapped[int] = mapped_column(Integer, default=0)
|
||||
client_ip: Mapped[str] = mapped_column(String(128), default="")
|
||||
user_agent: Mapped[str] = mapped_column(String(512), default="")
|
||||
detail: Mapped[dict] = mapped_column(_JsonType, default=dict)
|
||||
|
||||
|
||||
class ApiToken(Base):
|
||||
"""Long-lived API token (MCP/scripts); hashed at rest."""
|
||||
|
||||
__tablename__ = "api_token"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
|
||||
name: Mapped[str] = mapped_column(String(128), default="")
|
||||
token_hash: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
||||
user_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
|
||||
last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
|
|
|||
|
|
@ -11,7 +11,9 @@ from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSo
|
|||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .db import get_db
|
||||
from .db import SessionLocal, get_db
|
||||
from .auth_deps import resolve_user_from_token
|
||||
from .config import settings
|
||||
from .webcrt_service import (
|
||||
close_session,
|
||||
create_session,
|
||||
|
|
@ -74,6 +76,23 @@ def api_close_session(session_id: str, request: Request) -> dict[str, Any]:
|
|||
|
||||
@router.websocket("/sessions/{session_id}/ws")
|
||||
async def websocket_session(websocket: WebSocket, session_id: str) -> None:
|
||||
if bool(settings.auth_enabled):
|
||||
token = str(websocket.query_params.get("access_token") or "").strip()
|
||||
if not token:
|
||||
auth = str(websocket.headers.get("authorization") or "").strip()
|
||||
if auth.lower().startswith("bearer "):
|
||||
token = auth[7:].strip()
|
||||
db = SessionLocal()
|
||||
try:
|
||||
resolved = resolve_user_from_token(db, token) if token else None
|
||||
finally:
|
||||
db.close()
|
||||
if resolved is None:
|
||||
await websocket.close(code=4401)
|
||||
return
|
||||
websocket.state.auth_user = resolved[0]
|
||||
websocket.state.auth_via = resolved[1]
|
||||
|
||||
await websocket.accept()
|
||||
attach_gen = 0
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
"args": ["-m", "netx_mcp"],
|
||||
"env": {
|
||||
"NETX_API_URL": "http://127.0.0.1:8890",
|
||||
"NETX_API_TOKEN": "",
|
||||
"NETX_LANG": "zh",
|
||||
"PYTHONIOENCODING": "utf-8",
|
||||
"PYTHONUTF8": "1"
|
||||
|
|
|
|||
|
|
@ -29,6 +29,25 @@ def api_base_url() -> str:
|
|||
def api_headers() -> dict[str, str]:
|
||||
h = {"accept": "application/json"}
|
||||
tok = (os.getenv("NETX_API_TOKEN") or os.getenv("OCLAW_NETX_API_TOKEN") or "").strip()
|
||||
if not tok:
|
||||
# Lab default written by netx API bootstrap: data/auth/mcp_token
|
||||
candidates = [
|
||||
os.getenv("NETX_MCP_TOKEN_FILE", "").strip(),
|
||||
"data/auth/mcp_token",
|
||||
os.path.join(os.path.dirname(__file__), "..", "..", "..", "data", "auth", "mcp_token"),
|
||||
]
|
||||
for raw in candidates:
|
||||
if not raw:
|
||||
continue
|
||||
path = os.path.abspath(raw)
|
||||
try:
|
||||
if os.path.isfile(path):
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
tok = fh.read().strip()
|
||||
if tok:
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if tok:
|
||||
h["authorization"] = f"Bearer {tok}"
|
||||
return h
|
||||
|
|
|
|||
|
|
@ -13,3 +13,5 @@ python-multipart>=0.0.9
|
|||
websocket-client>=1.8.0
|
||||
cryptography>=42.0.0
|
||||
netmiko>=4.3.0
|
||||
bcrypt>=4.1.0
|
||||
PyJWT>=2.8.0
|
||||
|
|
|
|||
238
tests/test_auth.py
Normal file
238
tests/test_auth.py
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
"""Auth login, bootstrap, gate, and admin user management tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from netx_api.auth_middleware import AuthAuditMiddleware
|
||||
from netx_api.auth_passwords import hash_password, verify_password
|
||||
from netx_api.auth_router import router as auth_router
|
||||
from netx_api.auth_service import bootstrap_admin_if_needed, create_user
|
||||
from netx_api.auth_tokens import decode_access_token, issue_access_token
|
||||
from netx_api.db import Base, get_db
|
||||
from netx_api.models import AppUser, AuditLog
|
||||
|
||||
|
||||
class AuthUnitTests(unittest.TestCase):
|
||||
def test_password_hash_roundtrip(self) -> None:
|
||||
h = hash_password("secret123")
|
||||
self.assertTrue(verify_password("secret123", h))
|
||||
self.assertFalse(verify_password("wrong", h))
|
||||
|
||||
def test_jwt_roundtrip(self) -> None:
|
||||
with patch("netx_api.auth_tokens.settings") as st:
|
||||
st.auth_secret = "test-secret-key-for-jwt"
|
||||
st.auth_token_ttl_sec = 3600
|
||||
tok = issue_access_token(user_id="u1", username="admin", role="admin")
|
||||
payload = decode_access_token(tok)
|
||||
self.assertEqual(payload["sub"], "u1")
|
||||
self.assertEqual(payload["username"], "admin")
|
||||
self.assertEqual(payload["role"], "admin")
|
||||
|
||||
|
||||
class AuthApiTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine(
|
||||
"sqlite+pysqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=self.engine)
|
||||
self.Session = sessionmaker(bind=self.engine, autoflush=False, autocommit=False)
|
||||
|
||||
self.app = FastAPI()
|
||||
self.app.add_middleware(AuthAuditMiddleware)
|
||||
self.app.include_router(auth_router)
|
||||
|
||||
@self.app.get("/v1/probe")
|
||||
def probe() -> dict[str, str]:
|
||||
return {"ok": "1"}
|
||||
|
||||
def _override_db():
|
||||
db = self.Session()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
self.app.dependency_overrides[get_db] = _override_db
|
||||
|
||||
self._sess_patch = patch("netx_api.auth_middleware.SessionLocal", self.Session)
|
||||
self._sess_patch.start()
|
||||
self._settings_patches = [
|
||||
patch("netx_api.auth_middleware.settings.auth_enabled", True),
|
||||
patch("netx_api.auth_tokens.settings.auth_secret", "unit-test-auth-secret-32bytes!!"),
|
||||
patch("netx_api.auth_tokens.settings.auth_token_ttl_sec", 3600),
|
||||
patch("netx_api.auth_service.settings.bootstrap_admin_username", "admin"),
|
||||
patch("netx_api.auth_service.settings.bootstrap_admin_password", "adminpass"),
|
||||
patch("netx_api.auth_deps.settings.auth_enabled", True),
|
||||
]
|
||||
for p in self._settings_patches:
|
||||
p.start()
|
||||
|
||||
db = self.Session()
|
||||
try:
|
||||
bootstrap_admin_if_needed(db)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
self.client = TestClient(self.app)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._sess_patch.stop()
|
||||
for p in self._settings_patches:
|
||||
p.stop()
|
||||
self.app.dependency_overrides.clear()
|
||||
self.engine.dispose()
|
||||
|
||||
def _login(self, username: str = "admin", password: str = "adminpass") -> str:
|
||||
r = self.client.post("/v1/auth/login", json={"username": username, "password": password})
|
||||
self.assertEqual(r.status_code, 200, r.text)
|
||||
return str(r.json()["access_token"])
|
||||
|
||||
def test_bootstrap_creates_admin_once(self) -> None:
|
||||
db = self.Session()
|
||||
try:
|
||||
users = db.query(AppUser).all()
|
||||
self.assertEqual(len(users), 1)
|
||||
self.assertEqual(users[0].username, "admin")
|
||||
self.assertEqual(users[0].role, "admin")
|
||||
bootstrap_admin_if_needed(db)
|
||||
self.assertEqual(db.query(AppUser).count(), 1)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def test_bootstrap_requires_password_change(self) -> None:
|
||||
db = self.Session()
|
||||
try:
|
||||
admin = db.query(AppUser).filter(AppUser.username == "admin").one()
|
||||
self.assertTrue(admin.must_change_password)
|
||||
finally:
|
||||
db.close()
|
||||
token = self._login()
|
||||
me = self.client.get("/v1/auth/me", headers={"Authorization": f"Bearer {token}"})
|
||||
self.assertTrue(me.json()["user"]["must_change_password"])
|
||||
bad = self.client.post(
|
||||
"/v1/auth/change-password",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"old_password": "adminpass", "new_password": "adminpass"},
|
||||
)
|
||||
self.assertEqual(bad.status_code, 400)
|
||||
ok = self.client.post(
|
||||
"/v1/auth/change-password",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"old_password": "adminpass", "new_password": "newpass99"},
|
||||
)
|
||||
self.assertEqual(ok.status_code, 200, ok.text)
|
||||
me2 = self.client.get("/v1/auth/me", headers={"Authorization": f"Bearer {token}"})
|
||||
self.assertFalse(me2.json()["user"]["must_change_password"])
|
||||
|
||||
def test_login_and_me(self) -> None:
|
||||
token = self._login()
|
||||
r = self.client.get("/v1/auth/me", headers={"Authorization": f"Bearer {token}"})
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertEqual(r.json()["user"]["username"], "admin")
|
||||
|
||||
def test_probe_requires_auth(self) -> None:
|
||||
r = self.client.get("/v1/probe")
|
||||
self.assertEqual(r.status_code, 401)
|
||||
token = self._login()
|
||||
r2 = self.client.get("/v1/probe", headers={"Authorization": f"Bearer {token}"})
|
||||
self.assertEqual(r2.status_code, 200)
|
||||
|
||||
def test_login_failed_audited(self) -> None:
|
||||
r = self.client.post("/v1/auth/login", json={"username": "admin", "password": "bad"})
|
||||
self.assertEqual(r.status_code, 401)
|
||||
db = self.Session()
|
||||
try:
|
||||
row = (
|
||||
db.query(AuditLog)
|
||||
.filter(AuditLog.action == "auth.login_failed")
|
||||
.order_by(AuditLog.ts.desc())
|
||||
.first()
|
||||
)
|
||||
self.assertIsNotNone(row)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def test_non_admin_cannot_create_user(self) -> None:
|
||||
db = self.Session()
|
||||
try:
|
||||
admin = db.query(AppUser).filter(AppUser.username == "admin").one()
|
||||
create_user(db, username="alice", password="alice12", role="user", actor=admin)
|
||||
finally:
|
||||
db.close()
|
||||
token = self._login("alice", "alice12")
|
||||
r = self.client.post(
|
||||
"/v1/users",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"username": "bob", "password": "bob12345", "role": "user"},
|
||||
)
|
||||
self.assertEqual(r.status_code, 403)
|
||||
|
||||
def test_admin_create_user_and_list_audit(self) -> None:
|
||||
token = self._login()
|
||||
r = self.client.post(
|
||||
"/v1/users",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"username": "bob", "password": "bob12345", "role": "user"},
|
||||
)
|
||||
self.assertEqual(r.status_code, 200, r.text)
|
||||
self.assertEqual(r.json()["user"]["username"], "bob")
|
||||
audit = self.client.get("/v1/audit-logs", headers={"Authorization": f"Bearer {token}"})
|
||||
self.assertEqual(audit.status_code, 200)
|
||||
self.assertGreaterEqual(audit.json()["total"], 1)
|
||||
|
||||
def test_api_token_with_expiry(self) -> None:
|
||||
token = self._login()
|
||||
created = self.client.post(
|
||||
"/v1/api-tokens",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"name": "short", "expires_in_days": 7},
|
||||
)
|
||||
self.assertEqual(created.status_code, 200, created.text)
|
||||
body = created.json()["token"]
|
||||
self.assertTrue(body.get("expires_at"))
|
||||
api_tok = body["token"]
|
||||
r = self.client.get("/v1/probe", headers={"Authorization": f"Bearer {api_tok}"})
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
# Admin creates for another user
|
||||
self.client.post(
|
||||
"/v1/users",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"username": "carol", "password": "carol12", "role": "user"},
|
||||
)
|
||||
users = self.client.get("/v1/users", headers={"Authorization": f"Bearer {token}"})
|
||||
carol_id = next(u["id"] for u in users.json()["items"] if u["username"] == "carol")
|
||||
for_user = self.client.post(
|
||||
"/v1/api-tokens",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"name": "for-carol", "expires_in_days": 30, "user_id": carol_id},
|
||||
)
|
||||
self.assertEqual(for_user.status_code, 200, for_user.text)
|
||||
self.assertEqual(for_user.json()["token"]["username"], "carol")
|
||||
|
||||
def test_api_token_auth(self) -> None:
|
||||
token = self._login()
|
||||
created = self.client.post(
|
||||
"/v1/api-tokens",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"name": "mcp"},
|
||||
)
|
||||
self.assertEqual(created.status_code, 200, created.text)
|
||||
api_tok = created.json()["token"]["token"]
|
||||
self.assertTrue(str(api_tok).startswith("nxt_"))
|
||||
r = self.client.get("/v1/probe", headers={"Authorization": f"Bearer {api_tok}"})
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -8,16 +8,38 @@ import { NePage } from "./pages/NePage";
|
|||
import { UmePage } from "./pages/UmePage";
|
||||
import { WebcrtPage } from "./pages/WebcrtPage";
|
||||
import { TopologyPage } from "./pages/TopologyPage";
|
||||
import { LoginPage } from "./pages/LoginPage";
|
||||
import { UsersPage } from "./pages/UsersPage";
|
||||
import { AuditPage } from "./pages/AuditPage";
|
||||
import { ApiTokensPage } from "./pages/ApiTokensPage";
|
||||
import { ForceChangePasswordPage } from "./pages/ForceChangePasswordPage";
|
||||
import { fetchIntegrationStatus } from "./services/api";
|
||||
import { useAuth } from "./auth/AuthContext";
|
||||
|
||||
function App() {
|
||||
function ProtectedApp() {
|
||||
const { ready, user } = useAuth();
|
||||
const integrationsQuery = useQuery({
|
||||
queryKey: queryKeys.integrationsStatus,
|
||||
queryFn: fetchIntegrationStatus,
|
||||
refetchInterval: 5000,
|
||||
staleTime: 2000,
|
||||
enabled: ready && Boolean(user) && !user?.must_change_password,
|
||||
});
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-card">Loading…</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
if (user.must_change_password) {
|
||||
return <ForceChangePasswordPage />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AppLayout
|
||||
connections={{
|
||||
|
|
@ -53,10 +75,22 @@ function App() {
|
|||
<Route path="/collect" element={<CollectPage />} />
|
||||
<Route path="/webcrt" element={<WebcrtPage />} />
|
||||
<Route path="/topology" element={<TopologyPage />} />
|
||||
<Route path="/users" element={<UsersPage />} />
|
||||
<Route path="/audit" element={<AuditPage />} />
|
||||
<Route path="/api-keys" element={<ApiTokensPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/*" element={<ProtectedApp />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
|
|
|||
108
web/src/auth/AuthContext.tsx
Normal file
108
web/src/auth/AuthContext.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { apiGet, apiPost, clearAuthToken, getAuthToken, setAuthToken } from "../services/api";
|
||||
|
||||
export type AuthUser = {
|
||||
id: string;
|
||||
username: string;
|
||||
role: string;
|
||||
is_active: boolean;
|
||||
must_change_password?: boolean;
|
||||
created_by?: string;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
type AuthState = {
|
||||
ready: boolean;
|
||||
token: string | null;
|
||||
user: AuthUser | null;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
refreshMe: () => Promise<void>;
|
||||
isAdmin: boolean;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthState | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [ready, setReady] = useState(false);
|
||||
const [token, setToken] = useState<string | null>(() => getAuthToken());
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
|
||||
const refreshMe = useCallback(async () => {
|
||||
const tok = getAuthToken();
|
||||
if (!tok) {
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await apiGet<{ user: AuthUser }>("/v1/auth/me");
|
||||
setToken(tok);
|
||||
setUser(data.user);
|
||||
} catch {
|
||||
clearAuthToken();
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
await refreshMe();
|
||||
setReady(true);
|
||||
})();
|
||||
}, [refreshMe]);
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
const data = await apiPost<{ access_token: string; user: AuthUser }>("/v1/auth/login", {
|
||||
username,
|
||||
password,
|
||||
});
|
||||
setAuthToken(data.access_token);
|
||||
setToken(data.access_token);
|
||||
setUser(data.user);
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
if (getAuthToken()) {
|
||||
await apiPost("/v1/auth/logout", {});
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
clearAuthToken();
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
const value = useMemo<AuthState>(
|
||||
() => ({
|
||||
ready,
|
||||
token,
|
||||
user,
|
||||
login,
|
||||
logout,
|
||||
refreshMe,
|
||||
isAdmin: user?.role === "admin",
|
||||
}),
|
||||
[ready, token, user, login, logout, refreshMe],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthState {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error("useAuth outside AuthProvider");
|
||||
return ctx;
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
*/
|
||||
|
||||
export type ModuleIconTone = "blue" | "green" | "amber" | "slate";
|
||||
export type WorkbenchSection = "monitoring" | "operations";
|
||||
export type WorkbenchSection = "monitoring" | "operations" | "system";
|
||||
|
||||
export type ModuleDefinition = {
|
||||
moduleId: string;
|
||||
|
|
@ -13,6 +13,7 @@ export type ModuleDefinition = {
|
|||
descKey?: string;
|
||||
iconTone: ModuleIconTone;
|
||||
titleKey: string;
|
||||
adminOnly?: boolean;
|
||||
};
|
||||
|
||||
export const MODULES: readonly ModuleDefinition[] = [
|
||||
|
|
@ -61,7 +62,35 @@ export const MODULES: readonly ModuleDefinition[] = [
|
|||
iconTone: "blue",
|
||||
titleKey: "layout.titleTopology",
|
||||
},
|
||||
] as const;
|
||||
{
|
||||
moduleId: "users",
|
||||
path: "/users",
|
||||
section: "system",
|
||||
labelKey: "workbench.cards.users",
|
||||
descKey: "workbench.cards.usersDesc",
|
||||
iconTone: "slate",
|
||||
titleKey: "layout.titleUsers",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
moduleId: "audit",
|
||||
path: "/audit",
|
||||
section: "system",
|
||||
labelKey: "workbench.cards.audit",
|
||||
descKey: "workbench.cards.auditDesc",
|
||||
iconTone: "amber",
|
||||
titleKey: "layout.titleAudit",
|
||||
},
|
||||
{
|
||||
moduleId: "api-keys",
|
||||
path: "/api-keys",
|
||||
section: "system",
|
||||
labelKey: "workbench.cards.apiKeys",
|
||||
descKey: "workbench.cards.apiKeysDesc",
|
||||
iconTone: "green",
|
||||
titleKey: "layout.titleApiKeys",
|
||||
},
|
||||
] as const satisfies readonly ModuleDefinition[];
|
||||
|
||||
export function getModuleById(moduleId: string): ModuleDefinition | undefined {
|
||||
return MODULES.find((m) => m.moduleId === moduleId);
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ const en = {
|
|||
openModule: "Open or switch to this module tab",
|
||||
monitoring: "Monitoring",
|
||||
operations: "Operations",
|
||||
system: "System",
|
||||
cards: {
|
||||
umeSync: "UME Sync",
|
||||
umeSyncDesc: "UME alarms, subscription & inventory",
|
||||
|
|
@ -30,6 +31,12 @@ const en = {
|
|||
webcrtDesc: "Interactive browser login to connected NEs (SSH/Telnet)",
|
||||
topology: "Topology",
|
||||
topologyDesc: "Drag NE topology maps; discover links via LLDP/CDP",
|
||||
users: "Users",
|
||||
usersDesc: "Admin: create, disable users and reset passwords",
|
||||
audit: "Audit",
|
||||
auditDesc: "View login and operation records",
|
||||
apiKeys: "API Keys",
|
||||
apiKeysDesc: "Issue MCP/script tokens per user with expiry",
|
||||
},
|
||||
},
|
||||
layout: {
|
||||
|
|
@ -39,6 +46,9 @@ const en = {
|
|||
titleCollect: "Batch Collect",
|
||||
titleWebcrt: "WebCRT",
|
||||
titleTopology: "Topology",
|
||||
titleUsers: "Users",
|
||||
titleAudit: "Audit",
|
||||
titleApiKeys: "API Keys",
|
||||
navUme: "UME",
|
||||
netxApi: "netx api",
|
||||
oclawBridge: "oclaw WSS",
|
||||
|
|
@ -47,6 +57,79 @@ const en = {
|
|||
langZh: "中文",
|
||||
langEn: "English",
|
||||
},
|
||||
auth: {
|
||||
loginTitle: "Sign in to NetX",
|
||||
loginHint: "Use a local account to access the ops platform",
|
||||
username: "Username",
|
||||
password: "Password",
|
||||
login: "Sign in",
|
||||
loggingIn: "Signing in…",
|
||||
loginFailed: "Login failed",
|
||||
logout: "Sign out",
|
||||
usersTitle: "User management",
|
||||
usersHint: "Only admins can create and manage local accounts.",
|
||||
addUser: "Add user",
|
||||
role: "Role",
|
||||
roleAdmin: "Admin",
|
||||
roleUser: "User",
|
||||
status: "Status",
|
||||
active: "Active",
|
||||
disabled: "Disabled",
|
||||
enable: "Enable",
|
||||
disable: "Disable",
|
||||
actions: "Actions",
|
||||
newPassword: "New password",
|
||||
resetPassword: "Reset password",
|
||||
userCreated: "User created",
|
||||
userUpdated: "User updated",
|
||||
auditTitle: "Audit log",
|
||||
auditHintAdmin: "View login and operation records for all users.",
|
||||
auditHintUser: "View your own operation records.",
|
||||
filterUsername: "Filter username",
|
||||
filterAction: "Filter action",
|
||||
colTime: "Time",
|
||||
colUser: "User",
|
||||
colAction: "Action",
|
||||
colMethod: "Method",
|
||||
colPath: "Path",
|
||||
colStatus: "Status",
|
||||
colIp: "IP",
|
||||
apiKeysTitle: "API Key management",
|
||||
apiKeysHint:
|
||||
"Create long-lived tokens for MCP/scripts. The secret is shown only once. Admins can issue keys for other users.",
|
||||
tokenName: "Name",
|
||||
expiresIn: "Expiry",
|
||||
expire7d: "7 days",
|
||||
expire30d: "30 days",
|
||||
expire90d: "90 days",
|
||||
expire365d: "1 year",
|
||||
expireNever: "Never",
|
||||
tokenOwner: "Owner",
|
||||
tokenOwnerSelf: "Myself ({{user}})",
|
||||
createToken: "Create key",
|
||||
tokenCreated: "API key created",
|
||||
tokenRevoked: "Revoked",
|
||||
tokenOnceHint: "Copy and store this secret now; it will not be shown again:",
|
||||
copyToken: "Copy",
|
||||
tokenCopied: "Copied",
|
||||
tokenCopyFailed: "Copy failed",
|
||||
expiresAt: "Expires",
|
||||
lastUsed: "Last used",
|
||||
tokenStatusActive: "Active",
|
||||
tokenStatusExpired: "Expired",
|
||||
tokenStatusRevoked: "Revoked",
|
||||
revokeToken: "Revoke",
|
||||
revokeConfirm: "Revoke this API key?",
|
||||
forceChangeTitle: "Change initial password",
|
||||
forceChangeHint: "Account {{user}} is still using the default password. You must change it before continuing.",
|
||||
oldPassword: "Current password",
|
||||
confirmPassword: "Confirm new password",
|
||||
savePassword: "Save new password",
|
||||
savingPassword: "Saving…",
|
||||
passwordTooShort: "New password must be at least 6 characters",
|
||||
passwordMismatch: "New passwords do not match",
|
||||
passwordMustChange: "New password must differ from the default/old password",
|
||||
},
|
||||
collect: {
|
||||
create: {
|
||||
title: "New collection job",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ const zh = {
|
|||
openModule: "打开或切换到该模块页签",
|
||||
monitoring: "监控",
|
||||
operations: "运维",
|
||||
system: "系统管理",
|
||||
cards: {
|
||||
umeSync: "UME同步",
|
||||
umeSyncDesc: "UME 告警同步、订阅与清单",
|
||||
|
|
@ -30,6 +31,12 @@ const zh = {
|
|||
webcrtDesc: "浏览器内交互登录已连通网元(SSH/Telnet)",
|
||||
topology: "拓扑管理",
|
||||
topologyDesc: "拖拽编排网元拓扑,支持 LLDP/CDP 发现链路",
|
||||
users: "用户管理",
|
||||
usersDesc: "管理员添加、禁用用户并重置密码",
|
||||
audit: "操作审计",
|
||||
auditDesc: "查看登录与操作记录",
|
||||
apiKeys: "API Key",
|
||||
apiKeysDesc: "为用户生成 MCP/脚本用 Token,可设有效期",
|
||||
},
|
||||
},
|
||||
layout: {
|
||||
|
|
@ -39,6 +46,9 @@ const zh = {
|
|||
titleCollect: "批量采集",
|
||||
titleWebcrt: "WebCRT",
|
||||
titleTopology: "拓扑管理",
|
||||
titleUsers: "用户管理",
|
||||
titleAudit: "操作审计",
|
||||
titleApiKeys: "API Key",
|
||||
navUme: "UME 对接",
|
||||
netxApi: "netx api",
|
||||
oclawBridge: "oclaw WSS",
|
||||
|
|
@ -47,6 +57,78 @@ const zh = {
|
|||
langZh: "中文",
|
||||
langEn: "English",
|
||||
},
|
||||
auth: {
|
||||
loginTitle: "登录 NetX",
|
||||
loginHint: "使用本地账号访问运维平台",
|
||||
username: "用户名",
|
||||
password: "密码",
|
||||
login: "登录",
|
||||
loggingIn: "登录中…",
|
||||
loginFailed: "登录失败",
|
||||
logout: "退出",
|
||||
usersTitle: "用户管理",
|
||||
usersHint: "仅管理员可创建与管理本地账号。",
|
||||
addUser: "添加用户",
|
||||
role: "角色",
|
||||
roleAdmin: "管理员",
|
||||
roleUser: "普通用户",
|
||||
status: "状态",
|
||||
active: "启用",
|
||||
disabled: "禁用",
|
||||
enable: "启用",
|
||||
disable: "禁用",
|
||||
actions: "操作",
|
||||
newPassword: "新密码",
|
||||
resetPassword: "重置密码",
|
||||
userCreated: "用户已创建",
|
||||
userUpdated: "用户已更新",
|
||||
auditTitle: "操作审计",
|
||||
auditHintAdmin: "查看所有用户的登录与操作记录。",
|
||||
auditHintUser: "查看你自己的操作记录。",
|
||||
filterUsername: "用户名筛选",
|
||||
filterAction: "动作筛选",
|
||||
colTime: "时间",
|
||||
colUser: "用户",
|
||||
colAction: "动作",
|
||||
colMethod: "方法",
|
||||
colPath: "路径",
|
||||
colStatus: "状态码",
|
||||
colIp: "IP",
|
||||
apiKeysTitle: "API Key 管理",
|
||||
apiKeysHint: "生成长期 Token 供 MCP/脚本调用;明文仅创建时显示一次。管理员可为其他用户签发。",
|
||||
tokenName: "名称",
|
||||
expiresIn: "有效期",
|
||||
expire7d: "7 天",
|
||||
expire30d: "30 天",
|
||||
expire90d: "90 天",
|
||||
expire365d: "1 年",
|
||||
expireNever: "永不过期",
|
||||
tokenOwner: "所属用户",
|
||||
tokenOwnerSelf: "自己({{user}})",
|
||||
createToken: "生成 Key",
|
||||
tokenCreated: "API Key 已生成",
|
||||
tokenRevoked: "已吊销",
|
||||
tokenOnceHint: "请立即复制保存,关闭后无法再次查看明文:",
|
||||
copyToken: "复制",
|
||||
tokenCopied: "已复制到剪贴板",
|
||||
tokenCopyFailed: "复制失败",
|
||||
expiresAt: "到期时间",
|
||||
lastUsed: "最近使用",
|
||||
tokenStatusActive: "有效",
|
||||
tokenStatusExpired: "已过期",
|
||||
tokenStatusRevoked: "已吊销",
|
||||
revokeToken: "吊销",
|
||||
revokeConfirm: "确定吊销该 API Key?",
|
||||
forceChangeTitle: "请修改初始密码",
|
||||
forceChangeHint: "账号 {{user}} 仍在使用默认密码,登录前必须先修改。",
|
||||
oldPassword: "当前密码",
|
||||
confirmPassword: "确认新密码",
|
||||
savePassword: "保存新密码",
|
||||
savingPassword: "保存中…",
|
||||
passwordTooShort: "新密码至少 6 位",
|
||||
passwordMismatch: "两次输入的新密码不一致",
|
||||
passwordMustChange: "新密码不能与默认/旧密码相同",
|
||||
},
|
||||
collect: {
|
||||
create: {
|
||||
title: "新建采集任务",
|
||||
|
|
|
|||
|
|
@ -2343,3 +2343,79 @@ pre {
|
|||
min-height: 420px;
|
||||
}
|
||||
}
|
||||
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background:
|
||||
radial-gradient(ellipse at 20% 20%, rgba(33, 150, 243, 0.18), transparent 55%),
|
||||
radial-gradient(ellipse at 80% 0%, rgba(21, 101, 192, 0.2), transparent 45%),
|
||||
#eef2f7;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: min(400px, 100%);
|
||||
padding: 28px 28px 24px;
|
||||
background: #fff;
|
||||
border: 1px solid #d8dee8;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.login-card__brand {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #1565c0;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.login-card__title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.login-card__hint {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.login-card__label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.login-card__label input {
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.login-card__error {
|
||||
color: #b91c1c;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.login-card__submit {
|
||||
margin-top: 4px;
|
||||
height: 38px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: #1565c0;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.login-card__submit:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { getPageTitleKey, isWorkbenchPath } from "../config/modules";
|
|||
import { useAppWindowRegistration } from "../hooks/useAppWindowRegistration";
|
||||
import { useI18n } from "../i18n";
|
||||
import { returnToWorkbench } from "../utils/workbench";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
|
||||
type ConnLevel = "up" | "down" | "unknown";
|
||||
|
||||
|
|
@ -26,6 +27,7 @@ type Props = {
|
|||
export function AppLayout({ connections, children }: Props) {
|
||||
const { t } = useI18n();
|
||||
const { pathname } = useLocation();
|
||||
const { user, logout } = useAuth();
|
||||
const onWorkbench = isWorkbenchPath(pathname);
|
||||
const pageTitle = t(getPageTitleKey(pathname));
|
||||
const netxSuffix =
|
||||
|
|
@ -86,6 +88,20 @@ export function AppLayout({ connections, children }: Props) {
|
|||
{t("layout.oclawBridge")}: {connections.oclawBridge}
|
||||
{oclawSuffix}
|
||||
</span>
|
||||
{user ? (
|
||||
<span className="conn-pill conn-pill--on-brand conn-pill--up" title={user.role}>
|
||||
{user.username}
|
||||
</span>
|
||||
) : null}
|
||||
{user ? (
|
||||
<button
|
||||
type="button"
|
||||
className="header-menu__trigger header-menu__trigger--on-brand"
|
||||
onClick={() => void logout()}
|
||||
>
|
||||
{t("auth.logout")}
|
||||
</button>
|
||||
) : null}
|
||||
<HeaderMenu />
|
||||
</div>
|
||||
</header>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import App from "./App.tsx";
|
|||
import { ErrorBoundary } from "./layout/ErrorBoundary";
|
||||
import { I18nProvider } from "./i18n";
|
||||
import { ToastProvider } from "./hooks/useToast";
|
||||
import { AuthProvider } from "./auth/AuthContext";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
|
|
@ -17,7 +18,9 @@ createRoot(document.getElementById("root")!).render(
|
|||
<I18nProvider>
|
||||
<ToastProvider>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</ToastProvider>
|
||||
</I18nProvider>
|
||||
|
|
|
|||
199
web/src/pages/ApiTokensPage.tsx
Normal file
199
web/src/pages/ApiTokensPage.tsx
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import { useMemo, useState, type FormEvent } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { useI18n } from "../i18n";
|
||||
import { useToast } from "../hooks/useToast";
|
||||
import { apiDelete, apiGet, apiPost } from "../services/api";
|
||||
|
||||
type TokenRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
user_id: string;
|
||||
username: string;
|
||||
created_at: string | null;
|
||||
expires_at: string | null;
|
||||
last_used_at: string | null;
|
||||
revoked: boolean;
|
||||
expired: boolean;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
type UserRow = {
|
||||
id: string;
|
||||
username: string;
|
||||
role: string;
|
||||
is_active: boolean;
|
||||
};
|
||||
|
||||
const EXPIRY_OPTIONS = [
|
||||
{ value: 7, labelKey: "auth.expire7d" },
|
||||
{ value: 30, labelKey: "auth.expire30d" },
|
||||
{ value: 90, labelKey: "auth.expire90d" },
|
||||
{ value: 365, labelKey: "auth.expire365d" },
|
||||
{ value: 0, labelKey: "auth.expireNever" },
|
||||
] as const;
|
||||
|
||||
export function ApiTokensPage() {
|
||||
const { t } = useI18n();
|
||||
const { ready, user, isAdmin } = useAuth();
|
||||
const { showOk, showError } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const [name, setName] = useState("mcp");
|
||||
const [expiresInDays, setExpiresInDays] = useState(90);
|
||||
const [ownerUserId, setOwnerUserId] = useState("");
|
||||
const [createdPlain, setCreatedPlain] = useState("");
|
||||
|
||||
const tokensQuery = useQuery({
|
||||
queryKey: ["apiTokens"],
|
||||
queryFn: () => apiGet<{ items: TokenRow[] }>("/v1/api-tokens"),
|
||||
enabled: ready,
|
||||
});
|
||||
|
||||
const usersQuery = useQuery({
|
||||
queryKey: ["appUsers"],
|
||||
queryFn: () => apiGet<{ items: UserRow[] }>("/v1/users"),
|
||||
enabled: ready && isAdmin,
|
||||
});
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: () =>
|
||||
apiPost<{ token: TokenRow & { token: string } }>("/v1/api-tokens", {
|
||||
name: name.trim() || "mcp",
|
||||
expires_in_days: expiresInDays,
|
||||
user_id: isAdmin && ownerUserId ? ownerUserId : undefined,
|
||||
}),
|
||||
onSuccess: async (data) => {
|
||||
setCreatedPlain(data.token.token);
|
||||
showOk(t("auth.tokenCreated"));
|
||||
await qc.invalidateQueries({ queryKey: ["apiTokens"] });
|
||||
},
|
||||
onError: (e) => showError(String(e instanceof Error ? e.message : e)),
|
||||
});
|
||||
|
||||
const revokeMut = useMutation({
|
||||
mutationFn: (id: string) => apiDelete(`/v1/api-tokens/${encodeURIComponent(id)}`),
|
||||
onSuccess: async () => {
|
||||
showOk(t("auth.tokenRevoked"));
|
||||
await qc.invalidateQueries({ queryKey: ["apiTokens"] });
|
||||
},
|
||||
onError: (e) => showError(String(e instanceof Error ? e.message : e)),
|
||||
});
|
||||
|
||||
const items = useMemo(() => tokensQuery.data?.items || [], [tokensQuery.data]);
|
||||
const users = useMemo(() => usersQuery.data?.items || [], [usersQuery.data]);
|
||||
|
||||
const onCreate = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setCreatedPlain("");
|
||||
createMut.mutate();
|
||||
};
|
||||
|
||||
const copyToken = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(createdPlain);
|
||||
showOk(t("auth.tokenCopied"));
|
||||
} catch {
|
||||
showError(t("auth.tokenCopyFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="panel">
|
||||
<h2 className="panel__title">{t("auth.apiKeysTitle")}</h2>
|
||||
<p className="panel__hint">{t("auth.apiKeysHint")}</p>
|
||||
|
||||
<form className="form-row" onSubmit={onCreate} style={{ gap: 8, flexWrap: "wrap", marginBottom: 16 }}>
|
||||
<input
|
||||
placeholder={t("auth.tokenName")}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<select
|
||||
value={expiresInDays}
|
||||
onChange={(e) => setExpiresInDays(Number(e.target.value))}
|
||||
aria-label={t("auth.expiresIn")}
|
||||
>
|
||||
{EXPIRY_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{t(opt.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{isAdmin ? (
|
||||
<select
|
||||
value={ownerUserId}
|
||||
onChange={(e) => setOwnerUserId(e.target.value)}
|
||||
aria-label={t("auth.tokenOwner")}
|
||||
>
|
||||
<option value="">{t("auth.tokenOwnerSelf", { user: user?.username || "" })}</option>
|
||||
{users
|
||||
.filter((u) => u.is_active)
|
||||
.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.username} ({u.role})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : null}
|
||||
<button type="submit" disabled={createMut.isPending}>
|
||||
{t("auth.createToken")}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{createdPlain ? (
|
||||
<div className="panel" style={{ marginBottom: 16, background: "#f8fafc" }}>
|
||||
<div className="panel__hint">{t("auth.tokenOnceHint")}</div>
|
||||
<code style={{ wordBreak: "break-all", display: "block", margin: "8px 0" }}>{createdPlain}</code>
|
||||
<button type="button" onClick={() => void copyToken()}>
|
||||
{t("auth.copyToken")}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{tokensQuery.isLoading ? <div>{t("common.refreshing")}</div> : null}
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("auth.tokenName")}</th>
|
||||
<th>{t("auth.tokenOwner")}</th>
|
||||
<th>{t("auth.colTime")}</th>
|
||||
<th>{t("auth.expiresAt")}</th>
|
||||
<th>{t("auth.lastUsed")}</th>
|
||||
<th>{t("auth.status")}</th>
|
||||
<th>{t("auth.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td>{row.name}</td>
|
||||
<td>{row.username || row.user_id}</td>
|
||||
<td>{row.created_at || "-"}</td>
|
||||
<td>{row.expires_at || t("auth.expireNever")}</td>
|
||||
<td>{row.last_used_at || "-"}</td>
|
||||
<td>
|
||||
{row.revoked
|
||||
? t("auth.tokenStatusRevoked")
|
||||
: row.expired
|
||||
? t("auth.tokenStatusExpired")
|
||||
: t("auth.tokenStatusActive")}
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
disabled={row.revoked || revokeMut.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm(t("auth.revokeConfirm"))) revokeMut.mutate(row.id);
|
||||
}}
|
||||
>
|
||||
{t("auth.revokeToken")}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
119
web/src/pages/AuditPage.tsx
Normal file
119
web/src/pages/AuditPage.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { useI18n } from "../i18n";
|
||||
import { apiGet } from "../services/api";
|
||||
|
||||
type AuditItem = {
|
||||
id: string;
|
||||
ts: string | null;
|
||||
actor_username: string;
|
||||
action: string;
|
||||
method: string;
|
||||
path: string;
|
||||
status_code: number;
|
||||
client_ip: string;
|
||||
detail: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export function AuditPage() {
|
||||
const { t } = useI18n();
|
||||
const { ready, isAdmin } = useAuth();
|
||||
const [page, setPage] = useState(1);
|
||||
const [username, setUsername] = useState("");
|
||||
const [action, setAction] = useState("");
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["auditLogs", page, username, action],
|
||||
queryFn: () => {
|
||||
const p = new URLSearchParams();
|
||||
p.set("page", String(page));
|
||||
p.set("page_size", "50");
|
||||
if (username.trim()) p.set("username", username.trim());
|
||||
if (action.trim()) p.set("action", action.trim());
|
||||
return apiGet<{ total: number; page: number; page_size: number; items: AuditItem[] }>(
|
||||
`/v1/audit-logs?${p.toString()}`,
|
||||
);
|
||||
},
|
||||
enabled: ready,
|
||||
});
|
||||
|
||||
const items = useMemo(() => query.data?.items || [], [query.data]);
|
||||
const total = query.data?.total || 0;
|
||||
const pages = Math.max(1, Math.ceil(total / 50));
|
||||
|
||||
return (
|
||||
<div className="panel">
|
||||
<h2 className="panel__title">{t("auth.auditTitle")}</h2>
|
||||
<p className="panel__hint">{isAdmin ? t("auth.auditHintAdmin") : t("auth.auditHintUser")}</p>
|
||||
|
||||
<div className="form-row" style={{ gap: 8, flexWrap: "wrap", marginBottom: 12 }}>
|
||||
{isAdmin ? (
|
||||
<input
|
||||
placeholder={t("auth.filterUsername")}
|
||||
value={username}
|
||||
onChange={(e) => {
|
||||
setPage(1);
|
||||
setUsername(e.target.value);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<input
|
||||
placeholder={t("auth.filterAction")}
|
||||
value={action}
|
||||
onChange={(e) => {
|
||||
setPage(1);
|
||||
setAction(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<button type="button" onClick={() => void query.refetch()}>
|
||||
{t("common.refresh")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{query.isLoading ? <div>{t("common.refreshing")}</div> : null}
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("auth.colTime")}</th>
|
||||
<th>{t("auth.colUser")}</th>
|
||||
<th>{t("auth.colAction")}</th>
|
||||
<th>{t("auth.colMethod")}</th>
|
||||
<th>{t("auth.colPath")}</th>
|
||||
<th>{t("auth.colStatus")}</th>
|
||||
<th>{t("auth.colIp")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((row) => (
|
||||
<tr key={row.id} title={JSON.stringify(row.detail || {})}>
|
||||
<td>{row.ts || "-"}</td>
|
||||
<td>{row.actor_username || "-"}</td>
|
||||
<td>{row.action}</td>
|
||||
<td>{row.method}</td>
|
||||
<td style={{ maxWidth: 280, overflow: "hidden", textOverflow: "ellipsis" }}>{row.path}</td>
|
||||
<td>{row.status_code}</td>
|
||||
<td>{row.client_ip || "-"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="form-row" style={{ gap: 8, marginTop: 12 }}>
|
||||
<button type="button" disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
||||
{t("common.prevPage")}
|
||||
</button>
|
||||
<span>
|
||||
{t("common.pagerMeta", { total, page, pages })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={page >= pages}
|
||||
onClick={() => setPage((p) => Math.min(pages, p + 1))}
|
||||
>
|
||||
{t("common.nextPage")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
104
web/src/pages/ForceChangePasswordPage.tsx
Normal file
104
web/src/pages/ForceChangePasswordPage.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { useState, type FormEvent } from "react";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { useI18n } from "../i18n";
|
||||
import { apiPost } from "../services/api";
|
||||
|
||||
export function ForceChangePasswordPage() {
|
||||
const { t } = useI18n();
|
||||
const { user, refreshMe, logout } = useAuth();
|
||||
const [oldPassword, setOldPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const onSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
if (newPassword.length < 6) {
|
||||
setError(t("auth.passwordTooShort"));
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirm) {
|
||||
setError(t("auth.passwordMismatch"));
|
||||
return;
|
||||
}
|
||||
if (newPassword === oldPassword || newPassword === "admin123") {
|
||||
setError(t("auth.passwordMustChange"));
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await apiPost("/v1/auth/change-password", {
|
||||
old_password: oldPassword,
|
||||
new_password: newPassword,
|
||||
});
|
||||
await refreshMe();
|
||||
} catch (err) {
|
||||
setError(String(err instanceof Error ? err.message : err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<form className="login-card" onSubmit={(e) => void onSubmit(e)}>
|
||||
<div className="login-card__brand">NetX</div>
|
||||
<h1 className="login-card__title">{t("auth.forceChangeTitle")}</h1>
|
||||
<p className="login-card__hint">
|
||||
{t("auth.forceChangeHint", { user: user?.username || "admin" })}
|
||||
</p>
|
||||
<label className="login-card__label">
|
||||
{t("auth.oldPassword")}
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
autoFocus
|
||||
value={oldPassword}
|
||||
onChange={(e) => setOldPassword(e.target.value)}
|
||||
disabled={busy}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="login-card__label">
|
||||
{t("auth.newPassword")}
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
disabled={busy}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</label>
|
||||
<label className="login-card__label">
|
||||
{t("auth.confirmPassword")}
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
disabled={busy}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</label>
|
||||
{error ? <div className="login-card__error">{error}</div> : null}
|
||||
<button type="submit" className="login-card__submit" disabled={busy}>
|
||||
{busy ? t("auth.savingPassword") : t("auth.savePassword")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="login-card__submit"
|
||||
style={{ background: "#64748b" }}
|
||||
disabled={busy}
|
||||
onClick={() => void logout()}
|
||||
>
|
||||
{t("auth.logout")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
web/src/pages/LoginPage.tsx
Normal file
66
web/src/pages/LoginPage.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { useState, type FormEvent } from "react";
|
||||
import { Navigate, useSearchParams } from "react-router-dom";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { useI18n } from "../i18n";
|
||||
|
||||
export function LoginPage() {
|
||||
const { t } = useI18n();
|
||||
const { ready, user, login } = useAuth();
|
||||
const [params] = useSearchParams();
|
||||
const [username, setUsername] = useState("admin");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
if (ready && user) {
|
||||
const next = params.get("next") || "/";
|
||||
return <Navigate to={next.startsWith("/") ? next : "/"} replace />;
|
||||
}
|
||||
|
||||
const onSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setBusy(true);
|
||||
try {
|
||||
await login(username.trim(), password);
|
||||
} catch (err) {
|
||||
setError(String(err instanceof Error ? err.message : err) || t("auth.loginFailed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<form className="login-card" onSubmit={(e) => void onSubmit(e)}>
|
||||
<div className="login-card__brand">NetX</div>
|
||||
<h1 className="login-card__title">{t("auth.loginTitle")}</h1>
|
||||
<p className="login-card__hint">{t("auth.loginHint")}</p>
|
||||
<label className="login-card__label">
|
||||
{t("auth.username")}
|
||||
<input
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
disabled={busy || !ready}
|
||||
/>
|
||||
</label>
|
||||
<label className="login-card__label">
|
||||
{t("auth.password")}
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={busy || !ready}
|
||||
/>
|
||||
</label>
|
||||
{error ? <div className="login-card__error">{error}</div> : null}
|
||||
<button type="submit" className="login-card__submit" disabled={busy || !ready || !password}>
|
||||
{busy ? t("auth.loggingIn") : t("auth.login")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
152
web/src/pages/UsersPage.tsx
Normal file
152
web/src/pages/UsersPage.tsx
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import { useMemo, useState, type FormEvent } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { useI18n } from "../i18n";
|
||||
import { useToast } from "../hooks/useToast";
|
||||
import { apiGet, apiPatch, apiPost } from "../services/api";
|
||||
|
||||
type UserRow = {
|
||||
id: string;
|
||||
username: string;
|
||||
role: string;
|
||||
is_active: boolean;
|
||||
created_at?: string | null;
|
||||
};
|
||||
|
||||
export function UsersPage() {
|
||||
const { t } = useI18n();
|
||||
const { isAdmin, ready } = useAuth();
|
||||
const { showOk, showError } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [role, setRole] = useState("user");
|
||||
const [resetPwd, setResetPwd] = useState<Record<string, string>>({});
|
||||
|
||||
const usersQuery = useQuery({
|
||||
queryKey: ["appUsers"],
|
||||
queryFn: () => apiGet<{ items: UserRow[] }>("/v1/users"),
|
||||
enabled: ready && isAdmin,
|
||||
});
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: () => apiPost("/v1/users", { username, password, role }),
|
||||
onSuccess: async () => {
|
||||
setUsername("");
|
||||
setPassword("");
|
||||
setRole("user");
|
||||
showOk(t("auth.userCreated"));
|
||||
await qc.invalidateQueries({ queryKey: ["appUsers"] });
|
||||
},
|
||||
onError: (e) => showError(String(e instanceof Error ? e.message : e)),
|
||||
});
|
||||
|
||||
const patchMut = useMutation({
|
||||
mutationFn: (payload: { id: string; body: Record<string, unknown> }) =>
|
||||
apiPatch(`/v1/users/${encodeURIComponent(payload.id)}`, payload.body),
|
||||
onSuccess: async () => {
|
||||
showOk(t("auth.userUpdated"));
|
||||
await qc.invalidateQueries({ queryKey: ["appUsers"] });
|
||||
},
|
||||
onError: (e) => showError(String(e instanceof Error ? e.message : e)),
|
||||
});
|
||||
|
||||
const items = useMemo(() => usersQuery.data?.items || [], [usersQuery.data]);
|
||||
|
||||
if (ready && !isAdmin) return <Navigate to="/" replace />;
|
||||
|
||||
const onCreate = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
createMut.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="panel">
|
||||
<h2 className="panel__title">{t("auth.usersTitle")}</h2>
|
||||
<p className="panel__hint">{t("auth.usersHint")}</p>
|
||||
|
||||
<form className="form-row" onSubmit={onCreate} style={{ gap: 8, flexWrap: "wrap", marginBottom: 16 }}>
|
||||
<input
|
||||
placeholder={t("auth.username")}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder={t("auth.password")}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
<select value={role} onChange={(e) => setRole(e.target.value)}>
|
||||
<option value="user">{t("auth.roleUser")}</option>
|
||||
<option value="admin">{t("auth.roleAdmin")}</option>
|
||||
</select>
|
||||
<button type="submit" disabled={createMut.isPending}>
|
||||
{t("auth.addUser")}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{usersQuery.isLoading ? <div>{t("common.refreshing")}</div> : null}
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("auth.username")}</th>
|
||||
<th>{t("auth.role")}</th>
|
||||
<th>{t("auth.status")}</th>
|
||||
<th>{t("auth.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((u) => (
|
||||
<tr key={u.id}>
|
||||
<td>{u.username}</td>
|
||||
<td>{u.role === "admin" ? t("auth.roleAdmin") : t("auth.roleUser")}</td>
|
||||
<td>{u.is_active ? t("auth.active") : t("auth.disabled")}</td>
|
||||
<td>
|
||||
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", alignItems: "center" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
patchMut.mutate({ id: u.id, body: { is_active: !u.is_active } })
|
||||
}
|
||||
>
|
||||
{u.is_active ? t("auth.disable") : t("auth.enable")}
|
||||
</button>
|
||||
<select
|
||||
value={u.role}
|
||||
onChange={(e) => patchMut.mutate({ id: u.id, body: { role: e.target.value } })}
|
||||
>
|
||||
<option value="user">{t("auth.roleUser")}</option>
|
||||
<option value="admin">{t("auth.roleAdmin")}</option>
|
||||
</select>
|
||||
<input
|
||||
type="password"
|
||||
placeholder={t("auth.newPassword")}
|
||||
value={resetPwd[u.id] || ""}
|
||||
onChange={(e) => setResetPwd((m) => ({ ...m, [u.id]: e.target.value }))}
|
||||
style={{ width: 140 }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!resetPwd[u.id] || resetPwd[u.id].length < 6}
|
||||
onClick={() => {
|
||||
const pwd = resetPwd[u.id];
|
||||
patchMut.mutate({ id: u.id, body: { password: pwd } });
|
||||
setResetPwd((m) => ({ ...m, [u.id]: "" }));
|
||||
}}
|
||||
>
|
||||
{t("auth.resetPassword")}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,11 +2,13 @@ import { useI18n } from "../i18n";
|
|||
import { WorkbenchCardIcon } from "../components/WorkbenchCardIcon";
|
||||
import { modulesInSection, type WorkbenchSection } from "../config/modules";
|
||||
import { openOrFocusModule } from "../utils/moduleWindows";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
|
||||
const SECTIONS: WorkbenchSection[] = ["monitoring", "operations"];
|
||||
const SECTIONS: WorkbenchSection[] = ["monitoring", "operations", "system"];
|
||||
|
||||
export function WorkbenchPage() {
|
||||
const { t } = useI18n();
|
||||
const { isAdmin } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="workbench">
|
||||
|
|
@ -14,7 +16,9 @@ export function WorkbenchPage() {
|
|||
<section key={section} className="wb-section">
|
||||
<h2 className="wb-section__title">{t(`workbench.${section}`)}</h2>
|
||||
<div className="wb-grid">
|
||||
{modulesInSection(section).map((mod) => (
|
||||
{modulesInSection(section)
|
||||
.filter((mod) => !mod.adminOnly || isAdmin)
|
||||
.map((mod) => (
|
||||
<button
|
||||
key={mod.moduleId}
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -25,6 +25,44 @@ import type {
|
|||
TopologyMapItem,
|
||||
} from "../types";
|
||||
|
||||
const AUTH_TOKEN_KEY = "netx_access_token";
|
||||
|
||||
export const getAuthToken = (): string | null => {
|
||||
try {
|
||||
return localStorage.getItem(AUTH_TOKEN_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const setAuthToken = (token: string): void => {
|
||||
localStorage.setItem(AUTH_TOKEN_KEY, String(token || ""));
|
||||
};
|
||||
|
||||
export const clearAuthToken = (): void => {
|
||||
try {
|
||||
localStorage.removeItem(AUTH_TOKEN_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const authHeaders = (extra?: Record<string, string>): Record<string, string> => {
|
||||
const h: Record<string, string> = { accept: "application/json", ...(extra || {}) };
|
||||
const tok = getAuthToken();
|
||||
if (tok) h.authorization = `Bearer ${tok}`;
|
||||
return h;
|
||||
};
|
||||
|
||||
const handleUnauthorized = (path: string): void => {
|
||||
if (path.startsWith("/v1/auth/login")) return;
|
||||
clearAuthToken();
|
||||
if (typeof window !== "undefined" && !window.location.pathname.startsWith("/login")) {
|
||||
const next = `${window.location.pathname}${window.location.search || ""}`;
|
||||
window.location.assign(`/login?next=${encodeURIComponent(next)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const parseApiResponse = async (res: Response): Promise<Record<string, unknown>> => {
|
||||
const text = await res.text();
|
||||
if (!text) return {};
|
||||
|
|
@ -37,7 +75,11 @@ const parseApiResponse = async (res: Response): Promise<Record<string, unknown>>
|
|||
};
|
||||
|
||||
export const apiGet = async <T,>(path: string): Promise<T> => {
|
||||
const res = await fetch(path, { headers: { accept: "application/json" } });
|
||||
const res = await fetch(path, { headers: authHeaders() });
|
||||
if (res.status === 401) {
|
||||
handleUnauthorized(path);
|
||||
throw new Error("401 unauthorized");
|
||||
}
|
||||
if (!res.ok) throw new Error(`${res.status} ${path}`);
|
||||
return (await res.json()) as T;
|
||||
};
|
||||
|
|
@ -45,10 +87,14 @@ export const apiGet = async <T,>(path: string): Promise<T> => {
|
|||
export const apiPost = async <T,>(path: string, body: unknown): Promise<T> => {
|
||||
const res = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", accept: "application/json" },
|
||||
headers: authHeaders({ "content-type": "application/json" }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await parseApiResponse(res);
|
||||
if (res.status === 401) {
|
||||
handleUnauthorized(path);
|
||||
throw new Error(String(data.detail || "unauthorized"));
|
||||
}
|
||||
if (!res.ok) throw new Error(String(data.detail || `${res.status} ${path}`));
|
||||
return data as T;
|
||||
};
|
||||
|
|
@ -56,17 +102,25 @@ export const apiPost = async <T,>(path: string, body: unknown): Promise<T> => {
|
|||
export const apiPatch = async <T,>(path: string, body: unknown): Promise<T> => {
|
||||
const res = await fetch(path, {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json", accept: "application/json" },
|
||||
headers: authHeaders({ "content-type": "application/json" }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await parseApiResponse(res);
|
||||
if (res.status === 401) {
|
||||
handleUnauthorized(path);
|
||||
throw new Error(String(data.detail || "unauthorized"));
|
||||
}
|
||||
if (!res.ok) throw new Error(String(data.detail || `${res.status} ${path}`));
|
||||
return data as T;
|
||||
};
|
||||
|
||||
export const apiDelete = async <T,>(path: string): Promise<T> => {
|
||||
const res = await fetch(path, { method: "DELETE", headers: { accept: "application/json" } });
|
||||
const res = await fetch(path, { method: "DELETE", headers: authHeaders() });
|
||||
const data = await parseApiResponse(res);
|
||||
if (res.status === 401) {
|
||||
handleUnauthorized(path);
|
||||
throw new Error(String(data.detail || "unauthorized"));
|
||||
}
|
||||
if (!res.ok) throw new Error(String(data.detail || `${res.status} ${path}`));
|
||||
return data as T;
|
||||
};
|
||||
|
|
@ -74,10 +128,14 @@ export const apiDelete = async <T,>(path: string): Promise<T> => {
|
|||
export const apiPut = async <T,>(path: string, body: unknown): Promise<T> => {
|
||||
const res = await fetch(path, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json", accept: "application/json" },
|
||||
headers: authHeaders({ "content-type": "application/json" }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await parseApiResponse(res);
|
||||
if (res.status === 401) {
|
||||
handleUnauthorized(path);
|
||||
throw new Error(String(data.detail || "unauthorized"));
|
||||
}
|
||||
if (!res.ok) throw new Error(String(data.detail || `${res.status} ${path}`));
|
||||
return data as T;
|
||||
};
|
||||
|
|
@ -369,18 +427,20 @@ export const closeWebcrtSession = (sessionId: string) =>
|
|||
export const webcrtWsUrl = (sessionId: string): string => {
|
||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const path = `/v1/webcrt/sessions/${encodeURIComponent(sessionId)}/ws`;
|
||||
const tok = getAuthToken();
|
||||
const qs = tok ? `?access_token=${encodeURIComponent(tok)}` : "";
|
||||
// Optional override, e.g. ws://127.0.0.1:8890
|
||||
const override = String((import.meta as ImportMeta & { env?: Record<string, string> }).env?.VITE_NETX_WS_BASE || "").trim();
|
||||
if (override) {
|
||||
return `${override.replace(/\/$/, "")}${path}`;
|
||||
return `${override.replace(/\/$/, "")}${path}${qs}`;
|
||||
}
|
||||
// Vite/preview: HTTP is proxied, but WS proxy is often flaky — hit API directly.
|
||||
const port = window.location.port;
|
||||
if (port === "5173" || port === "4173") {
|
||||
const apiHost = window.location.hostname === "localhost" ? "127.0.0.1" : window.location.hostname;
|
||||
return `${proto}//${apiHost}:8890${path}`;
|
||||
return `${proto}//${apiHost}:8890${path}${qs}`;
|
||||
}
|
||||
return `${proto}//${window.location.host}${path}`;
|
||||
return `${proto}//${window.location.host}${path}${qs}`;
|
||||
};
|
||||
|
||||
export const fetchCliMeta = () => apiGet<CliMeta>("/v1/cli/meta");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue