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:
hansjone 2026-07-30 02:34:25 +00:00
parent 14f14d34bd
commit 6d4cd741ef
35 changed files with 2699 additions and 21 deletions

View file

@ -37,3 +37,9 @@ NETX_UME_ALARM_WS_ENABLED=true
NETX_UME_NOTIFICATION_ESTABLISH_PATH=/restconf/operations/zte-notifications:establish-subscription 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_DELETE_PATH=/restconf/operations/zte-notifications:delete-subscription
NETX_UME_NOTIFICATION_TOPIC=ALARM 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
View file

@ -10,3 +10,4 @@ scripts/.run/
ume/ ume/
data/ne_collections/ data/ne_collections/
data/webcrt/ data/webcrt/
data/auth/

View file

@ -92,8 +92,39 @@ Option B: local `.env` (recommended)
NETX_DATABASE_URL=postgresql+psycopg://postgres:admin123@127.0.0.1:5432/netx NETX_DATABASE_URL=postgresql+psycopg://postgres:admin123@127.0.0.1:5432/netx
NETX_OCLAW_ANALYZE_TOKEN=admin123 NETX_OCLAW_ANALYZE_TOKEN=admin123
NETX_OCLAW_HEALTH_URL=http://127.0.0.1:8787/admin/api/ops-ai/health 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 ### 5) Start services
Direct backend start: Direct backend start:

View file

@ -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_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_LANG` | 否 | `zh` | `zh` / `en`,影响 API 文案 |
| `NETX_NE_EXEC_MAX_COMMANDS` | 否 | `5` | `execManagedNe` 单次最多命令数(硬上限 50);API 与 MCP 需同设 | | `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"], "args": ["-m", "netx_mcp"],
"env": { "env": {
"NETX_API_URL": "http://127.0.0.1:8890", "NETX_API_URL": "http://127.0.0.1:8890",
"NETX_API_TOKEN": "",
"NETX_LANG": "zh", "NETX_LANG": "zh",
"PYTHONIOENCODING": "utf-8", "PYTHONIOENCODING": "utf-8",
"PYTHONUTF8": "1" "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 子进程重新拉起。 保存后 **重启 Cursor/客户端**,使 MCP 子进程重新拉起。
--- ---

View file

@ -5,7 +5,6 @@
"args": ["-m", "netx_mcp"], "args": ["-m", "netx_mcp"],
"env": { "env": {
"NETX_API_URL": "http://127.0.0.1:8890", "NETX_API_URL": "http://127.0.0.1:8890",
"NETX_API_TOKEN": "",
"NETX_LANG": "zh", "NETX_LANG": "zh",
"PYTHONIOENCODING": "utf-8", "PYTHONIOENCODING": "utf-8",
"PYTHONUTF8": "1" "PYTHONUTF8": "1"

101
netx_api/auth_deps.py Normal file
View 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
View 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

View 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
View 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
View 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
View 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
View 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()

View file

@ -80,6 +80,15 @@ class Settings(BaseSettings):
webcrt_connect_timeout_sec: int = 90 webcrt_connect_timeout_sec: int = 90
webcrt_attach_timeout_sec: int = 60 webcrt_attach_timeout_sec: int = 60
webcrt_data_dir: str = "data/webcrt" 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() settings = Settings()

View file

@ -19,6 +19,9 @@ from typing import Any
import uvicorn import uvicorn
from .ap_client import analyze_with_oclaw, health_with_oclaw 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 .config import settings
from .db import Base, SessionLocal, engine, get_db from .db import Base, SessionLocal, engine, get_db
from .collection_router import router as collection_router from .collection_router import router as collection_router
@ -31,6 +34,9 @@ from .models import (
AiAnalyzeHistory, AiAnalyzeHistory,
AlarmBatch, AlarmBatch,
AlarmNorm, AlarmNorm,
ApiToken,
AppUser,
AuditLog,
ImportErrorRow, ImportErrorRow,
ManagedNE, ManagedNE,
NeCollectionJob, NeCollectionJob,
@ -122,6 +128,8 @@ from .schemas import (
) )
app = FastAPI(title="netx ops tool", version="0.1.0") 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(managed_ne_router)
app.include_router(cli_router) app.include_router(cli_router)
app.include_router(collection_router) app.include_router(collection_router)
@ -794,6 +802,15 @@ def on_startup() -> None:
_configure_ume_diag_logging() _configure_ume_diag_logging()
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
_migrate_key_alert_rule_schema() _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() _reset_runtime_pause_flags()
_fail_stale_running_sync_jobs_on_startup() _fail_stale_running_sync_jobs_on_startup()
if _needs_startup_alarm_sync_before_ws(): if _needs_startup_alarm_sync_before_ws():
@ -806,6 +823,10 @@ def on_startup() -> None:
complete_startup_alarm_sync_gate() complete_startup_alarm_sync_gate()
db = SessionLocal() db = SessionLocal()
try: 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 from .collection_recovery import recover_collection_jobs_on_startup
resumed = recover_collection_jobs_on_startup(db) 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_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 ne_name")
conn.exec_driver_sql("ALTER TABLE ume_alarms_history DROP COLUMN IF EXISTS user_label") 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_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_vendor VARCHAR(32) DEFAULT 'zte'")
conn.exec_driver_sql("ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_host VARCHAR(128) DEFAULT ''") conn.exec_driver_sql("ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_host VARCHAR(128) DEFAULT ''")

View file

@ -3,11 +3,16 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from uuid import uuid4 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.orm import Mapped, mapped_column, relationship
from sqlalchemy.types import JSON
from .db import Base from .db import Base
# JSONB on Postgres; plain JSON elsewhere (unit tests / sqlite).
_JsonType = JSON().with_variant(JSONB(), "postgresql")
class AlarmBatch(Base): class AlarmBatch(Base):
__tablename__ = "alarm_batches" __tablename__ = "alarm_batches"
@ -430,3 +435,52 @@ class TopologyEdge(Base):
discovered_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) discovered_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_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)

View file

@ -11,7 +11,9 @@ from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSo
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy.orm import Session 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 ( from .webcrt_service import (
close_session, close_session,
create_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") @router.websocket("/sessions/{session_id}/ws")
async def websocket_session(websocket: WebSocket, session_id: str) -> None: 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() await websocket.accept()
attach_gen = 0 attach_gen = 0
try: try:

View file

@ -5,7 +5,6 @@
"args": ["-m", "netx_mcp"], "args": ["-m", "netx_mcp"],
"env": { "env": {
"NETX_API_URL": "http://127.0.0.1:8890", "NETX_API_URL": "http://127.0.0.1:8890",
"NETX_API_TOKEN": "",
"NETX_LANG": "zh", "NETX_LANG": "zh",
"PYTHONIOENCODING": "utf-8", "PYTHONIOENCODING": "utf-8",
"PYTHONUTF8": "1" "PYTHONUTF8": "1"

View file

@ -29,6 +29,25 @@ def api_base_url() -> str:
def api_headers() -> dict[str, str]: def api_headers() -> dict[str, str]:
h = {"accept": "application/json"} h = {"accept": "application/json"}
tok = (os.getenv("NETX_API_TOKEN") or os.getenv("OCLAW_NETX_API_TOKEN") or "").strip() 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: if tok:
h["authorization"] = f"Bearer {tok}" h["authorization"] = f"Bearer {tok}"
return h return h

View file

@ -13,3 +13,5 @@ python-multipart>=0.0.9
websocket-client>=1.8.0 websocket-client>=1.8.0
cryptography>=42.0.0 cryptography>=42.0.0
netmiko>=4.3.0 netmiko>=4.3.0
bcrypt>=4.1.0
PyJWT>=2.8.0

238
tests/test_auth.py Normal file
View 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()

View file

@ -8,16 +8,38 @@ import { NePage } from "./pages/NePage";
import { UmePage } from "./pages/UmePage"; import { UmePage } from "./pages/UmePage";
import { WebcrtPage } from "./pages/WebcrtPage"; import { WebcrtPage } from "./pages/WebcrtPage";
import { TopologyPage } from "./pages/TopologyPage"; 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 { fetchIntegrationStatus } from "./services/api";
import { useAuth } from "./auth/AuthContext";
function App() { function ProtectedApp() {
const { ready, user } = useAuth();
const integrationsQuery = useQuery({ const integrationsQuery = useQuery({
queryKey: queryKeys.integrationsStatus, queryKey: queryKeys.integrationsStatus,
queryFn: fetchIntegrationStatus, queryFn: fetchIntegrationStatus,
refetchInterval: 5000, refetchInterval: 5000,
staleTime: 2000, 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 ( return (
<AppLayout <AppLayout
connections={{ connections={{
@ -53,10 +75,22 @@ function App() {
<Route path="/collect" element={<CollectPage />} /> <Route path="/collect" element={<CollectPage />} />
<Route path="/webcrt" element={<WebcrtPage />} /> <Route path="/webcrt" element={<WebcrtPage />} />
<Route path="/topology" element={<TopologyPage />} /> <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 />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</AppLayout> </AppLayout>
); );
} }
function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/*" element={<ProtectedApp />} />
</Routes>
);
}
export default App; export default App;

View 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;
}

View file

@ -3,7 +3,7 @@
*/ */
export type ModuleIconTone = "blue" | "green" | "amber" | "slate"; export type ModuleIconTone = "blue" | "green" | "amber" | "slate";
export type WorkbenchSection = "monitoring" | "operations"; export type WorkbenchSection = "monitoring" | "operations" | "system";
export type ModuleDefinition = { export type ModuleDefinition = {
moduleId: string; moduleId: string;
@ -13,6 +13,7 @@ export type ModuleDefinition = {
descKey?: string; descKey?: string;
iconTone: ModuleIconTone; iconTone: ModuleIconTone;
titleKey: string; titleKey: string;
adminOnly?: boolean;
}; };
export const MODULES: readonly ModuleDefinition[] = [ export const MODULES: readonly ModuleDefinition[] = [
@ -61,7 +62,35 @@ export const MODULES: readonly ModuleDefinition[] = [
iconTone: "blue", iconTone: "blue",
titleKey: "layout.titleTopology", 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 { export function getModuleById(moduleId: string): ModuleDefinition | undefined {
return MODULES.find((m) => m.moduleId === moduleId); return MODULES.find((m) => m.moduleId === moduleId);

View file

@ -19,6 +19,7 @@ const en = {
openModule: "Open or switch to this module tab", openModule: "Open or switch to this module tab",
monitoring: "Monitoring", monitoring: "Monitoring",
operations: "Operations", operations: "Operations",
system: "System",
cards: { cards: {
umeSync: "UME Sync", umeSync: "UME Sync",
umeSyncDesc: "UME alarms, subscription & inventory", umeSyncDesc: "UME alarms, subscription & inventory",
@ -30,6 +31,12 @@ const en = {
webcrtDesc: "Interactive browser login to connected NEs (SSH/Telnet)", webcrtDesc: "Interactive browser login to connected NEs (SSH/Telnet)",
topology: "Topology", topology: "Topology",
topologyDesc: "Drag NE topology maps; discover links via LLDP/CDP", 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: { layout: {
@ -39,6 +46,9 @@ const en = {
titleCollect: "Batch Collect", titleCollect: "Batch Collect",
titleWebcrt: "WebCRT", titleWebcrt: "WebCRT",
titleTopology: "Topology", titleTopology: "Topology",
titleUsers: "Users",
titleAudit: "Audit",
titleApiKeys: "API Keys",
navUme: "UME", navUme: "UME",
netxApi: "netx api", netxApi: "netx api",
oclawBridge: "oclaw WSS", oclawBridge: "oclaw WSS",
@ -47,6 +57,79 @@ const en = {
langZh: "中文", langZh: "中文",
langEn: "English", 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: { collect: {
create: { create: {
title: "New collection job", title: "New collection job",

View file

@ -19,6 +19,7 @@ const zh = {
openModule: "打开或切换到该模块页签", openModule: "打开或切换到该模块页签",
monitoring: "监控", monitoring: "监控",
operations: "运维", operations: "运维",
system: "系统管理",
cards: { cards: {
umeSync: "UME同步", umeSync: "UME同步",
umeSyncDesc: "UME 告警同步、订阅与清单", umeSyncDesc: "UME 告警同步、订阅与清单",
@ -30,6 +31,12 @@ const zh = {
webcrtDesc: "浏览器内交互登录已连通网元(SSH/Telnet)", webcrtDesc: "浏览器内交互登录已连通网元(SSH/Telnet)",
topology: "拓扑管理", topology: "拓扑管理",
topologyDesc: "拖拽编排网元拓扑,支持 LLDP/CDP 发现链路", topologyDesc: "拖拽编排网元拓扑,支持 LLDP/CDP 发现链路",
users: "用户管理",
usersDesc: "管理员添加、禁用用户并重置密码",
audit: "操作审计",
auditDesc: "查看登录与操作记录",
apiKeys: "API Key",
apiKeysDesc: "为用户生成 MCP/脚本用 Token,可设有效期",
}, },
}, },
layout: { layout: {
@ -39,6 +46,9 @@ const zh = {
titleCollect: "批量采集", titleCollect: "批量采集",
titleWebcrt: "WebCRT", titleWebcrt: "WebCRT",
titleTopology: "拓扑管理", titleTopology: "拓扑管理",
titleUsers: "用户管理",
titleAudit: "操作审计",
titleApiKeys: "API Key",
navUme: "UME 对接", navUme: "UME 对接",
netxApi: "netx api", netxApi: "netx api",
oclawBridge: "oclaw WSS", oclawBridge: "oclaw WSS",
@ -47,6 +57,78 @@ const zh = {
langZh: "中文", langZh: "中文",
langEn: "English", 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: { collect: {
create: { create: {
title: "新建采集任务", title: "新建采集任务",

View file

@ -2343,3 +2343,79 @@ pre {
min-height: 420px; 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;
}

View file

@ -6,6 +6,7 @@ import { getPageTitleKey, isWorkbenchPath } from "../config/modules";
import { useAppWindowRegistration } from "../hooks/useAppWindowRegistration"; import { useAppWindowRegistration } from "../hooks/useAppWindowRegistration";
import { useI18n } from "../i18n"; import { useI18n } from "../i18n";
import { returnToWorkbench } from "../utils/workbench"; import { returnToWorkbench } from "../utils/workbench";
import { useAuth } from "../auth/AuthContext";
type ConnLevel = "up" | "down" | "unknown"; type ConnLevel = "up" | "down" | "unknown";
@ -26,6 +27,7 @@ type Props = {
export function AppLayout({ connections, children }: Props) { export function AppLayout({ connections, children }: Props) {
const { t } = useI18n(); const { t } = useI18n();
const { pathname } = useLocation(); const { pathname } = useLocation();
const { user, logout } = useAuth();
const onWorkbench = isWorkbenchPath(pathname); const onWorkbench = isWorkbenchPath(pathname);
const pageTitle = t(getPageTitleKey(pathname)); const pageTitle = t(getPageTitleKey(pathname));
const netxSuffix = const netxSuffix =
@ -86,6 +88,20 @@ export function AppLayout({ connections, children }: Props) {
{t("layout.oclawBridge")}: {connections.oclawBridge} {t("layout.oclawBridge")}: {connections.oclawBridge}
{oclawSuffix} {oclawSuffix}
</span> </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 /> <HeaderMenu />
</div> </div>
</header> </header>

View file

@ -7,6 +7,7 @@ import App from "./App.tsx";
import { ErrorBoundary } from "./layout/ErrorBoundary"; import { ErrorBoundary } from "./layout/ErrorBoundary";
import { I18nProvider } from "./i18n"; import { I18nProvider } from "./i18n";
import { ToastProvider } from "./hooks/useToast"; import { ToastProvider } from "./hooks/useToast";
import { AuthProvider } from "./auth/AuthContext";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@ -17,7 +18,9 @@ createRoot(document.getElementById("root")!).render(
<I18nProvider> <I18nProvider>
<ToastProvider> <ToastProvider>
<BrowserRouter> <BrowserRouter>
<App /> <AuthProvider>
<App />
</AuthProvider>
</BrowserRouter> </BrowserRouter>
</ToastProvider> </ToastProvider>
</I18nProvider> </I18nProvider>

View 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
View 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>
);
}

View 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>
);
}

View 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
View 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>
);
}

View file

@ -2,11 +2,13 @@ import { useI18n } from "../i18n";
import { WorkbenchCardIcon } from "../components/WorkbenchCardIcon"; import { WorkbenchCardIcon } from "../components/WorkbenchCardIcon";
import { modulesInSection, type WorkbenchSection } from "../config/modules"; import { modulesInSection, type WorkbenchSection } from "../config/modules";
import { openOrFocusModule } from "../utils/moduleWindows"; import { openOrFocusModule } from "../utils/moduleWindows";
import { useAuth } from "../auth/AuthContext";
const SECTIONS: WorkbenchSection[] = ["monitoring", "operations"]; const SECTIONS: WorkbenchSection[] = ["monitoring", "operations", "system"];
export function WorkbenchPage() { export function WorkbenchPage() {
const { t } = useI18n(); const { t } = useI18n();
const { isAdmin } = useAuth();
return ( return (
<div className="workbench"> <div className="workbench">
@ -14,7 +16,9 @@ export function WorkbenchPage() {
<section key={section} className="wb-section"> <section key={section} className="wb-section">
<h2 className="wb-section__title">{t(`workbench.${section}`)}</h2> <h2 className="wb-section__title">{t(`workbench.${section}`)}</h2>
<div className="wb-grid"> <div className="wb-grid">
{modulesInSection(section).map((mod) => ( {modulesInSection(section)
.filter((mod) => !mod.adminOnly || isAdmin)
.map((mod) => (
<button <button
key={mod.moduleId} key={mod.moduleId}
type="button" type="button"

View file

@ -25,6 +25,44 @@ import type {
TopologyMapItem, TopologyMapItem,
} from "../types"; } 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 parseApiResponse = async (res: Response): Promise<Record<string, unknown>> => {
const text = await res.text(); const text = await res.text();
if (!text) return {}; 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> => { 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}`); if (!res.ok) throw new Error(`${res.status} ${path}`);
return (await res.json()) as T; 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> => { export const apiPost = async <T,>(path: string, body: unknown): Promise<T> => {
const res = await fetch(path, { const res = await fetch(path, {
method: "POST", method: "POST",
headers: { "content-type": "application/json", accept: "application/json" }, headers: authHeaders({ "content-type": "application/json" }),
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
const data = await parseApiResponse(res); 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}`)); if (!res.ok) throw new Error(String(data.detail || `${res.status} ${path}`));
return data as T; 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> => { export const apiPatch = async <T,>(path: string, body: unknown): Promise<T> => {
const res = await fetch(path, { const res = await fetch(path, {
method: "PATCH", method: "PATCH",
headers: { "content-type": "application/json", accept: "application/json" }, headers: authHeaders({ "content-type": "application/json" }),
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
const data = await parseApiResponse(res); 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}`)); if (!res.ok) throw new Error(String(data.detail || `${res.status} ${path}`));
return data as T; return data as T;
}; };
export const apiDelete = async <T,>(path: string): Promise<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); 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}`)); if (!res.ok) throw new Error(String(data.detail || `${res.status} ${path}`));
return data as T; 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> => { export const apiPut = async <T,>(path: string, body: unknown): Promise<T> => {
const res = await fetch(path, { const res = await fetch(path, {
method: "PUT", method: "PUT",
headers: { "content-type": "application/json", accept: "application/json" }, headers: authHeaders({ "content-type": "application/json" }),
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
const data = await parseApiResponse(res); 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}`)); if (!res.ok) throw new Error(String(data.detail || `${res.status} ${path}`));
return data as T; return data as T;
}; };
@ -369,18 +427,20 @@ export const closeWebcrtSession = (sessionId: string) =>
export const webcrtWsUrl = (sessionId: string): string => { export const webcrtWsUrl = (sessionId: string): string => {
const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
const path = `/v1/webcrt/sessions/${encodeURIComponent(sessionId)}/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 // 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(); const override = String((import.meta as ImportMeta & { env?: Record<string, string> }).env?.VITE_NETX_WS_BASE || "").trim();
if (override) { 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. // Vite/preview: HTTP is proxied, but WS proxy is often flaky — hit API directly.
const port = window.location.port; const port = window.location.port;
if (port === "5173" || port === "4173") { if (port === "5173" || port === "4173") {
const apiHost = window.location.hostname === "localhost" ? "127.0.0.1" : window.location.hostname; 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"); export const fetchCliMeta = () => apiGet<CliMeta>("/v1/cli/meta");