From 6d4cd741ef3c3ed03c955ff4cfdb7644daf4d0bf Mon Sep 17 00:00:00 2001 From: hansjone Date: Thu, 30 Jul 2026 02:34:25 +0000 Subject: [PATCH] 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 --- .env.example | 6 + .gitignore | 1 + README.md | 31 ++ docs/MCP.md | 12 +- mcp.json | 1 - netx_api/auth_deps.py | 101 ++++ netx_api/auth_middleware.py | 139 ++++++ netx_api/auth_passwords.py | 20 + netx_api/auth_router.py | 315 ++++++++++++ netx_api/auth_schemas.py | 35 ++ netx_api/auth_service.py | 463 ++++++++++++++++++ netx_api/auth_tokens.py | 62 +++ netx_api/config.py | 9 + netx_api/main.py | 25 + netx_api/models.py | 56 ++- netx_api/webcrt_router.py | 21 +- packages/netx-mcp/mcp.json | 1 - packages/netx-mcp/src/netx_mcp/http_client.py | 19 + requirements.txt | 2 + tests/test_auth.py | 238 +++++++++ web/src/App.tsx | 36 +- web/src/auth/AuthContext.tsx | 108 ++++ web/src/config/modules.ts | 33 +- web/src/i18n/en.ts | 83 ++++ web/src/i18n/zh.ts | 82 ++++ web/src/index.css | 76 +++ web/src/layout/AppLayout.tsx | 16 + web/src/main.tsx | 5 +- web/src/pages/ApiTokensPage.tsx | 199 ++++++++ web/src/pages/AuditPage.tsx | 119 +++++ web/src/pages/ForceChangePasswordPage.tsx | 104 ++++ web/src/pages/LoginPage.tsx | 66 +++ web/src/pages/UsersPage.tsx | 152 ++++++ web/src/pages/WorkbenchPage.tsx | 8 +- web/src/services/api.ts | 76 ++- 35 files changed, 2699 insertions(+), 21 deletions(-) create mode 100644 netx_api/auth_deps.py create mode 100644 netx_api/auth_middleware.py create mode 100644 netx_api/auth_passwords.py create mode 100644 netx_api/auth_router.py create mode 100644 netx_api/auth_schemas.py create mode 100644 netx_api/auth_service.py create mode 100644 netx_api/auth_tokens.py create mode 100644 tests/test_auth.py create mode 100644 web/src/auth/AuthContext.tsx create mode 100644 web/src/pages/ApiTokensPage.tsx create mode 100644 web/src/pages/AuditPage.tsx create mode 100644 web/src/pages/ForceChangePasswordPage.tsx create mode 100644 web/src/pages/LoginPage.tsx create mode 100644 web/src/pages/UsersPage.tsx diff --git a/.env.example b/.env.example index d079720..2f12ec3 100644 --- a/.env.example +++ b/.env.example @@ -37,3 +37,9 @@ NETX_UME_ALARM_WS_ENABLED=true NETX_UME_NOTIFICATION_ESTABLISH_PATH=/restconf/operations/zte-notifications:establish-subscription NETX_UME_NOTIFICATION_DELETE_PATH=/restconf/operations/zte-notifications:delete-subscription NETX_UME_NOTIFICATION_TOPIC=ALARM +# Auth (optional — lab defaults: admin/admin123 + data/auth/mcp_token) +# NETX_AUTH_ENABLED=true +# NETX_AUTH_SECRET=change-me-in-production +# NETX_BOOTSTRAP_ADMIN_USERNAME=admin +# NETX_BOOTSTRAP_ADMIN_PASSWORD=admin123 +# NETX_API_TOKEN= # MCP: leave empty to auto-read data/auth/mcp_token diff --git a/.gitignore b/.gitignore index c9ad659..2b2883a 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ scripts/.run/ ume/ data/ne_collections/ data/webcrt/ +data/auth/ diff --git a/README.md b/README.md index bd4c91b..36963c7 100644 --- a/README.md +++ b/README.md @@ -92,8 +92,39 @@ Option B: local `.env` (recommended) NETX_DATABASE_URL=postgresql+psycopg://postgres:admin123@127.0.0.1:5432/netx NETX_OCLAW_ANALYZE_TOKEN=admin123 NETX_OCLAW_HEALTH_URL=http://127.0.0.1:8787/admin/api/ops-ai/health +# App login (required for production) +NETX_AUTH_ENABLED=true +NETX_AUTH_SECRET=replace-with-a-long-random-string +NETX_BOOTSTRAP_ADMIN_USERNAME=admin +NETX_BOOTSTRAP_ADMIN_PASSWORD=change-me-on-first-boot ``` +### Auth & audit + +**不必改 `.env` 也能用(本机默认):** + +| 项 | 默认值 | +|----|--------| +| 登录账号 | `admin` / `admin123` | +| `NETX_AUTH_SECRET` | 内置开发密钥(生产请改) | +| MCP Token 文件 | 首次启动写入 `data/auth/mcp_token` | + +生产建议在 `.env` 覆盖: + +```env +NETX_AUTH_SECRET=your-long-random-secret +NETX_BOOTSTRAP_ADMIN_PASSWORD=your-strong-password +``` + +- Web:打开 `/login`,用 `admin` / `admin123`(首次建库后生效)。工作台有 **API Key** 页可为不同用户生成 Token 并设置有效期。 +- MCP:优先读环境变量 `NETX_API_TOKEN`;未设置时自动读 `data/auth/mcp_token`(API 启动时生成)。也可在 Cursor MCP 配置里显式填写: + +```json +"NETX_API_TOKEN": "nxt_...." +``` + +Token 内容见 `data/auth/mcp_token`,或登录后调用 `POST /v1/api-tokens` 新建。 + ### 5) Start services Direct backend start: diff --git a/docs/MCP.md b/docs/MCP.md index 9998966..b35b67d 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -67,11 +67,16 @@ pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx | 变量 | 必填 | 默认 | 说明 | |------|------|------|------| | `NETX_API_URL` | 否 | `http://127.0.0.1:8890` | netx REST 根地址,可指向远端 | -| `NETX_API_TOKEN` | 否 | 空 | API 启用 Bearer 时填写 | +| `NETX_API_TOKEN` | 否 | 空 | Bearer;空则自动读 `data/auth/mcp_token`(API 首次启动生成) | +| `NETX_MCP_TOKEN_FILE` | 否 | `data/auth/mcp_token` | 默认 token 文件路径 | | `NETX_LANG` | 否 | `zh` | `zh` / `en`,影响 API 文案 | | `NETX_NE_EXEC_MAX_COMMANDS` | 否 | `5` | `execManagedNe` 单次最多命令数(硬上限 50);API 与 MCP 需同设 | -本机默认端口时 **可不设任何变量**。 +本机默认端口时 **可不设任何变量**。启用登录后,先启动一次 netx API,会生成 `data/auth/mcp_token`;MCP 会自动带上该 token。若要把 token 写进 Cursor 配置: + +```json +"NETX_API_TOKEN": "nxt_从文件复制的内容" +``` --- @@ -87,7 +92,6 @@ pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx "args": ["-m", "netx_mcp"], "env": { "NETX_API_URL": "http://127.0.0.1:8890", - "NETX_API_TOKEN": "", "NETX_LANG": "zh", "PYTHONIOENCODING": "utf-8", "PYTHONUTF8": "1" @@ -97,6 +101,8 @@ pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx } ``` +(可选)显式设置 `"NETX_API_TOKEN": "nxt_..."`;不设则读仓库/`cwd` 下的 `data/auth/mcp_token`。 + 保存后 **重启 Cursor/客户端**,使 MCP 子进程重新拉起。 --- diff --git a/mcp.json b/mcp.json index 4ed18a7..f776735 100644 --- a/mcp.json +++ b/mcp.json @@ -5,7 +5,6 @@ "args": ["-m", "netx_mcp"], "env": { "NETX_API_URL": "http://127.0.0.1:8890", - "NETX_API_TOKEN": "", "NETX_LANG": "zh", "PYTHONIOENCODING": "utf-8", "PYTHONUTF8": "1" diff --git a/netx_api/auth_deps.py b/netx_api/auth_deps.py new file mode 100644 index 0000000..5771878 --- /dev/null +++ b/netx_api/auth_deps.py @@ -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 diff --git a/netx_api/auth_middleware.py b/netx_api/auth_middleware.py new file mode 100644 index 0000000..f8f8f3f --- /dev/null +++ b/netx_api/auth_middleware.py @@ -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 diff --git a/netx_api/auth_passwords.py b/netx_api/auth_passwords.py new file mode 100644 index 0000000..80f5234 --- /dev/null +++ b/netx_api/auth_passwords.py @@ -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 diff --git a/netx_api/auth_router.py b/netx_api/auth_router.py new file mode 100644 index 0000000..f465bc0 --- /dev/null +++ b/netx_api/auth_router.py @@ -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} diff --git a/netx_api/auth_schemas.py b/netx_api/auth_schemas.py new file mode 100644 index 0000000..77da0b9 --- /dev/null +++ b/netx_api/auth_schemas.py @@ -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 diff --git a/netx_api/auth_service.py b/netx_api/auth_service.py new file mode 100644 index 0000000..e792314 --- /dev/null +++ b/netx_api/auth_service.py @@ -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, + } diff --git a/netx_api/auth_tokens.py b/netx_api/auth_tokens.py new file mode 100644 index 0000000..7f76790 --- /dev/null +++ b/netx_api/auth_tokens.py @@ -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() diff --git a/netx_api/config.py b/netx_api/config.py index 948f0c5..4c1598a 100644 --- a/netx_api/config.py +++ b/netx_api/config.py @@ -80,6 +80,15 @@ class Settings(BaseSettings): webcrt_connect_timeout_sec: int = 90 webcrt_attach_timeout_sec: int = 60 webcrt_data_dir: str = "data/webcrt" + # Local app login / audit (lab defaults; override in production) + auth_enabled: bool = True + # Stable default so JWT survives restarts without .env. Override in production. + auth_secret: str = "netx-dev-auth-secret-change-me-in-production-32b" + auth_token_ttl_sec: int = 86400 + bootstrap_admin_username: str = "admin" + bootstrap_admin_password: str = "admin123" + # Written on first boot for MCP; path relative to cwd / absolute + auth_mcp_token_file: str = "data/auth/mcp_token" settings = Settings() diff --git a/netx_api/main.py b/netx_api/main.py index 0eaa141..4ed85bb 100644 --- a/netx_api/main.py +++ b/netx_api/main.py @@ -19,6 +19,9 @@ from typing import Any import uvicorn from .ap_client import analyze_with_oclaw, health_with_oclaw +from .auth_middleware import AuthAuditMiddleware +from .auth_router import router as auth_router +from .auth_service import bootstrap_admin_if_needed from .config import settings from .db import Base, SessionLocal, engine, get_db from .collection_router import router as collection_router @@ -31,6 +34,9 @@ from .models import ( AiAnalyzeHistory, AlarmBatch, AlarmNorm, + ApiToken, + AppUser, + AuditLog, ImportErrorRow, ManagedNE, NeCollectionJob, @@ -122,6 +128,8 @@ from .schemas import ( ) app = FastAPI(title="netx ops tool", version="0.1.0") +app.add_middleware(AuthAuditMiddleware) +app.include_router(auth_router) app.include_router(managed_ne_router) app.include_router(cli_router) app.include_router(collection_router) @@ -794,6 +802,15 @@ def on_startup() -> None: _configure_ume_diag_logging() Base.metadata.create_all(bind=engine) _migrate_key_alert_rule_schema() + # Auth columns must exist before bootstrap / flag_default_password_users. + try: + with engine.begin() as conn: + conn.exec_driver_sql( + "ALTER TABLE app_user ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN DEFAULT FALSE" + ) + conn.exec_driver_sql("ALTER TABLE api_token ADD COLUMN IF NOT EXISTS expires_at TIMESTAMP") + except Exception: + _schedule_log.exception("startup: auth schema migration failed") _reset_runtime_pause_flags() _fail_stale_running_sync_jobs_on_startup() if _needs_startup_alarm_sync_before_ws(): @@ -806,6 +823,10 @@ def on_startup() -> None: complete_startup_alarm_sync_gate() db = SessionLocal() try: + try: + bootstrap_admin_if_needed(db) + except Exception: + _schedule_log.exception("startup: auth bootstrap admin failed") from .collection_recovery import recover_collection_jobs_on_startup resumed = recover_collection_jobs_on_startup(db) @@ -899,6 +920,10 @@ def on_startup() -> None: conn.exec_driver_sql("ALTER TABLE ume_alarms_current DROP COLUMN IF EXISTS user_label") conn.exec_driver_sql("ALTER TABLE ume_alarms_history DROP COLUMN IF EXISTS ne_name") conn.exec_driver_sql("ALTER TABLE ume_alarms_history DROP COLUMN IF EXISTS user_label") + conn.exec_driver_sql("ALTER TABLE api_token ADD COLUMN IF NOT EXISTS expires_at TIMESTAMP") + conn.exec_driver_sql( + "ALTER TABLE app_user ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN DEFAULT FALSE" + ) conn.exec_driver_sql("ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_enabled BOOLEAN DEFAULT FALSE") conn.exec_driver_sql("ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_vendor VARCHAR(32) DEFAULT 'zte'") conn.exec_driver_sql("ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_host VARCHAR(128) DEFAULT ''") diff --git a/netx_api/models.py b/netx_api/models.py index d57d73a..b99facd 100644 --- a/netx_api/models.py +++ b/netx_api/models.py @@ -3,11 +3,16 @@ from __future__ import annotations from datetime import datetime from uuid import uuid4 -from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Text +from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text +from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.types import JSON from .db import Base +# JSONB on Postgres; plain JSON elsewhere (unit tests / sqlite). +_JsonType = JSON().with_variant(JSONB(), "postgresql") + class AlarmBatch(Base): __tablename__ = "alarm_batches" @@ -430,3 +435,52 @@ class TopologyEdge(Base): discovered_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + +class AppUser(Base): + """Local netx application user (login account).""" + + __tablename__ = "app_user" + + id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex) + username: Mapped[str] = mapped_column(String(128), unique=True, index=True) + password_hash: Mapped[str] = mapped_column(String(255), default="") + role: Mapped[str] = mapped_column(String(32), default="user", index=True) # admin | user + is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True) + must_change_password: Mapped[bool] = mapped_column(Boolean, default=False) + created_by: Mapped[str] = mapped_column(String(64), default="") + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + +class AuditLog(Base): + """Application audit trail for authenticated (and auth) actions.""" + + __tablename__ = "audit_log" + + id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex) + ts: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) + actor_user_id: Mapped[str] = mapped_column(String(64), default="", index=True) + actor_username: Mapped[str] = mapped_column(String(128), default="", index=True) + action: Mapped[str] = mapped_column(String(128), default="", index=True) + method: Mapped[str] = mapped_column(String(16), default="") + path: Mapped[str] = mapped_column(String(512), default="", index=True) + status_code: Mapped[int] = mapped_column(Integer, default=0) + client_ip: Mapped[str] = mapped_column(String(128), default="") + user_agent: Mapped[str] = mapped_column(String(512), default="") + detail: Mapped[dict] = mapped_column(_JsonType, default=dict) + + +class ApiToken(Base): + """Long-lived API token (MCP/scripts); hashed at rest.""" + + __tablename__ = "api_token" + + id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex) + name: Mapped[str] = mapped_column(String(128), default="") + token_hash: Mapped[str] = mapped_column(String(128), unique=True, index=True) + user_id: Mapped[str] = mapped_column(String(64), index=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True) + last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) diff --git a/netx_api/webcrt_router.py b/netx_api/webcrt_router.py index f00a940..e815751 100644 --- a/netx_api/webcrt_router.py +++ b/netx_api/webcrt_router.py @@ -11,7 +11,9 @@ from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSo from pydantic import BaseModel, Field from sqlalchemy.orm import Session -from .db import get_db +from .db import SessionLocal, get_db +from .auth_deps import resolve_user_from_token +from .config import settings from .webcrt_service import ( close_session, create_session, @@ -74,6 +76,23 @@ def api_close_session(session_id: str, request: Request) -> dict[str, Any]: @router.websocket("/sessions/{session_id}/ws") async def websocket_session(websocket: WebSocket, session_id: str) -> None: + if bool(settings.auth_enabled): + token = str(websocket.query_params.get("access_token") or "").strip() + if not token: + auth = str(websocket.headers.get("authorization") or "").strip() + if auth.lower().startswith("bearer "): + token = auth[7:].strip() + db = SessionLocal() + try: + resolved = resolve_user_from_token(db, token) if token else None + finally: + db.close() + if resolved is None: + await websocket.close(code=4401) + return + websocket.state.auth_user = resolved[0] + websocket.state.auth_via = resolved[1] + await websocket.accept() attach_gen = 0 try: diff --git a/packages/netx-mcp/mcp.json b/packages/netx-mcp/mcp.json index 4ed18a7..f776735 100644 --- a/packages/netx-mcp/mcp.json +++ b/packages/netx-mcp/mcp.json @@ -5,7 +5,6 @@ "args": ["-m", "netx_mcp"], "env": { "NETX_API_URL": "http://127.0.0.1:8890", - "NETX_API_TOKEN": "", "NETX_LANG": "zh", "PYTHONIOENCODING": "utf-8", "PYTHONUTF8": "1" diff --git a/packages/netx-mcp/src/netx_mcp/http_client.py b/packages/netx-mcp/src/netx_mcp/http_client.py index 0d312ee..60a153c 100644 --- a/packages/netx-mcp/src/netx_mcp/http_client.py +++ b/packages/netx-mcp/src/netx_mcp/http_client.py @@ -29,6 +29,25 @@ def api_base_url() -> str: def api_headers() -> dict[str, str]: h = {"accept": "application/json"} tok = (os.getenv("NETX_API_TOKEN") or os.getenv("OCLAW_NETX_API_TOKEN") or "").strip() + if not tok: + # Lab default written by netx API bootstrap: data/auth/mcp_token + candidates = [ + os.getenv("NETX_MCP_TOKEN_FILE", "").strip(), + "data/auth/mcp_token", + os.path.join(os.path.dirname(__file__), "..", "..", "..", "data", "auth", "mcp_token"), + ] + for raw in candidates: + if not raw: + continue + path = os.path.abspath(raw) + try: + if os.path.isfile(path): + with open(path, encoding="utf-8") as fh: + tok = fh.read().strip() + if tok: + break + except Exception: + continue if tok: h["authorization"] = f"Bearer {tok}" return h diff --git a/requirements.txt b/requirements.txt index 50a9808..1be1699 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,3 +13,5 @@ python-multipart>=0.0.9 websocket-client>=1.8.0 cryptography>=42.0.0 netmiko>=4.3.0 +bcrypt>=4.1.0 +PyJWT>=2.8.0 diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..96df175 --- /dev/null +++ b/tests/test_auth.py @@ -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() diff --git a/web/src/App.tsx b/web/src/App.tsx index e79d13f..7e23861 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -8,16 +8,38 @@ import { NePage } from "./pages/NePage"; import { UmePage } from "./pages/UmePage"; import { WebcrtPage } from "./pages/WebcrtPage"; import { TopologyPage } from "./pages/TopologyPage"; +import { LoginPage } from "./pages/LoginPage"; +import { UsersPage } from "./pages/UsersPage"; +import { AuditPage } from "./pages/AuditPage"; +import { ApiTokensPage } from "./pages/ApiTokensPage"; +import { ForceChangePasswordPage } from "./pages/ForceChangePasswordPage"; import { fetchIntegrationStatus } from "./services/api"; +import { useAuth } from "./auth/AuthContext"; -function App() { +function ProtectedApp() { + const { ready, user } = useAuth(); const integrationsQuery = useQuery({ queryKey: queryKeys.integrationsStatus, queryFn: fetchIntegrationStatus, refetchInterval: 5000, staleTime: 2000, + enabled: ready && Boolean(user) && !user?.must_change_password, }); + if (!ready) { + return ( +
+
Loading…
+
+ ); + } + if (!user) { + return ; + } + if (user.must_change_password) { + return ; + } + return ( } /> } /> } /> + } /> + } /> + } /> } /> ); } +function App() { + return ( + + } /> + } /> + + ); +} + export default App; diff --git a/web/src/auth/AuthContext.tsx b/web/src/auth/AuthContext.tsx new file mode 100644 index 0000000..7d7646a --- /dev/null +++ b/web/src/auth/AuthContext.tsx @@ -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; + logout: () => Promise; + refreshMe: () => Promise; + isAdmin: boolean; +}; + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [ready, setReady] = useState(false); + const [token, setToken] = useState(() => getAuthToken()); + const [user, setUser] = useState(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( + () => ({ + ready, + token, + user, + login, + logout, + refreshMe, + isAdmin: user?.role === "admin", + }), + [ready, token, user, login, logout, refreshMe], + ); + + return {children}; +} + +export function useAuth(): AuthState { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error("useAuth outside AuthProvider"); + return ctx; +} diff --git a/web/src/config/modules.ts b/web/src/config/modules.ts index 6d19833..b3c8114 100644 --- a/web/src/config/modules.ts +++ b/web/src/config/modules.ts @@ -3,7 +3,7 @@ */ export type ModuleIconTone = "blue" | "green" | "amber" | "slate"; -export type WorkbenchSection = "monitoring" | "operations"; +export type WorkbenchSection = "monitoring" | "operations" | "system"; export type ModuleDefinition = { moduleId: string; @@ -13,6 +13,7 @@ export type ModuleDefinition = { descKey?: string; iconTone: ModuleIconTone; titleKey: string; + adminOnly?: boolean; }; export const MODULES: readonly ModuleDefinition[] = [ @@ -61,7 +62,35 @@ export const MODULES: readonly ModuleDefinition[] = [ iconTone: "blue", titleKey: "layout.titleTopology", }, -] as const; + { + moduleId: "users", + path: "/users", + section: "system", + labelKey: "workbench.cards.users", + descKey: "workbench.cards.usersDesc", + iconTone: "slate", + titleKey: "layout.titleUsers", + adminOnly: true, + }, + { + moduleId: "audit", + path: "/audit", + section: "system", + labelKey: "workbench.cards.audit", + descKey: "workbench.cards.auditDesc", + iconTone: "amber", + titleKey: "layout.titleAudit", + }, + { + moduleId: "api-keys", + path: "/api-keys", + section: "system", + labelKey: "workbench.cards.apiKeys", + descKey: "workbench.cards.apiKeysDesc", + iconTone: "green", + titleKey: "layout.titleApiKeys", + }, +] as const satisfies readonly ModuleDefinition[]; export function getModuleById(moduleId: string): ModuleDefinition | undefined { return MODULES.find((m) => m.moduleId === moduleId); diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 9c380c8..06125ff 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -19,6 +19,7 @@ const en = { openModule: "Open or switch to this module tab", monitoring: "Monitoring", operations: "Operations", + system: "System", cards: { umeSync: "UME Sync", umeSyncDesc: "UME alarms, subscription & inventory", @@ -30,6 +31,12 @@ const en = { webcrtDesc: "Interactive browser login to connected NEs (SSH/Telnet)", topology: "Topology", topologyDesc: "Drag NE topology maps; discover links via LLDP/CDP", + users: "Users", + usersDesc: "Admin: create, disable users and reset passwords", + audit: "Audit", + auditDesc: "View login and operation records", + apiKeys: "API Keys", + apiKeysDesc: "Issue MCP/script tokens per user with expiry", }, }, layout: { @@ -39,6 +46,9 @@ const en = { titleCollect: "Batch Collect", titleWebcrt: "WebCRT", titleTopology: "Topology", + titleUsers: "Users", + titleAudit: "Audit", + titleApiKeys: "API Keys", navUme: "UME", netxApi: "netx api", oclawBridge: "oclaw WSS", @@ -47,6 +57,79 @@ const en = { langZh: "中文", langEn: "English", }, + auth: { + loginTitle: "Sign in to NetX", + loginHint: "Use a local account to access the ops platform", + username: "Username", + password: "Password", + login: "Sign in", + loggingIn: "Signing in…", + loginFailed: "Login failed", + logout: "Sign out", + usersTitle: "User management", + usersHint: "Only admins can create and manage local accounts.", + addUser: "Add user", + role: "Role", + roleAdmin: "Admin", + roleUser: "User", + status: "Status", + active: "Active", + disabled: "Disabled", + enable: "Enable", + disable: "Disable", + actions: "Actions", + newPassword: "New password", + resetPassword: "Reset password", + userCreated: "User created", + userUpdated: "User updated", + auditTitle: "Audit log", + auditHintAdmin: "View login and operation records for all users.", + auditHintUser: "View your own operation records.", + filterUsername: "Filter username", + filterAction: "Filter action", + colTime: "Time", + colUser: "User", + colAction: "Action", + colMethod: "Method", + colPath: "Path", + colStatus: "Status", + colIp: "IP", + apiKeysTitle: "API Key management", + apiKeysHint: + "Create long-lived tokens for MCP/scripts. The secret is shown only once. Admins can issue keys for other users.", + tokenName: "Name", + expiresIn: "Expiry", + expire7d: "7 days", + expire30d: "30 days", + expire90d: "90 days", + expire365d: "1 year", + expireNever: "Never", + tokenOwner: "Owner", + tokenOwnerSelf: "Myself ({{user}})", + createToken: "Create key", + tokenCreated: "API key created", + tokenRevoked: "Revoked", + tokenOnceHint: "Copy and store this secret now; it will not be shown again:", + copyToken: "Copy", + tokenCopied: "Copied", + tokenCopyFailed: "Copy failed", + expiresAt: "Expires", + lastUsed: "Last used", + tokenStatusActive: "Active", + tokenStatusExpired: "Expired", + tokenStatusRevoked: "Revoked", + revokeToken: "Revoke", + revokeConfirm: "Revoke this API key?", + forceChangeTitle: "Change initial password", + forceChangeHint: "Account {{user}} is still using the default password. You must change it before continuing.", + oldPassword: "Current password", + confirmPassword: "Confirm new password", + savePassword: "Save new password", + savingPassword: "Saving…", + passwordTooShort: "New password must be at least 6 characters", + passwordMismatch: "New passwords do not match", + passwordMustChange: "New password must differ from the default/old password", + }, collect: { create: { title: "New collection job", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 6b8efe9..1ef2cb5 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -19,6 +19,7 @@ const zh = { openModule: "打开或切换到该模块页签", monitoring: "监控", operations: "运维", + system: "系统管理", cards: { umeSync: "UME同步", umeSyncDesc: "UME 告警同步、订阅与清单", @@ -30,6 +31,12 @@ const zh = { webcrtDesc: "浏览器内交互登录已连通网元(SSH/Telnet)", topology: "拓扑管理", topologyDesc: "拖拽编排网元拓扑,支持 LLDP/CDP 发现链路", + users: "用户管理", + usersDesc: "管理员添加、禁用用户并重置密码", + audit: "操作审计", + auditDesc: "查看登录与操作记录", + apiKeys: "API Key", + apiKeysDesc: "为用户生成 MCP/脚本用 Token,可设有效期", }, }, layout: { @@ -39,6 +46,9 @@ const zh = { titleCollect: "批量采集", titleWebcrt: "WebCRT", titleTopology: "拓扑管理", + titleUsers: "用户管理", + titleAudit: "操作审计", + titleApiKeys: "API Key", navUme: "UME 对接", netxApi: "netx api", oclawBridge: "oclaw WSS", @@ -47,6 +57,78 @@ const zh = { langZh: "中文", langEn: "English", }, + auth: { + loginTitle: "登录 NetX", + loginHint: "使用本地账号访问运维平台", + username: "用户名", + password: "密码", + login: "登录", + loggingIn: "登录中…", + loginFailed: "登录失败", + logout: "退出", + usersTitle: "用户管理", + usersHint: "仅管理员可创建与管理本地账号。", + addUser: "添加用户", + role: "角色", + roleAdmin: "管理员", + roleUser: "普通用户", + status: "状态", + active: "启用", + disabled: "禁用", + enable: "启用", + disable: "禁用", + actions: "操作", + newPassword: "新密码", + resetPassword: "重置密码", + userCreated: "用户已创建", + userUpdated: "用户已更新", + auditTitle: "操作审计", + auditHintAdmin: "查看所有用户的登录与操作记录。", + auditHintUser: "查看你自己的操作记录。", + filterUsername: "用户名筛选", + filterAction: "动作筛选", + colTime: "时间", + colUser: "用户", + colAction: "动作", + colMethod: "方法", + colPath: "路径", + colStatus: "状态码", + colIp: "IP", + apiKeysTitle: "API Key 管理", + apiKeysHint: "生成长期 Token 供 MCP/脚本调用;明文仅创建时显示一次。管理员可为其他用户签发。", + tokenName: "名称", + expiresIn: "有效期", + expire7d: "7 天", + expire30d: "30 天", + expire90d: "90 天", + expire365d: "1 年", + expireNever: "永不过期", + tokenOwner: "所属用户", + tokenOwnerSelf: "自己({{user}})", + createToken: "生成 Key", + tokenCreated: "API Key 已生成", + tokenRevoked: "已吊销", + tokenOnceHint: "请立即复制保存,关闭后无法再次查看明文:", + copyToken: "复制", + tokenCopied: "已复制到剪贴板", + tokenCopyFailed: "复制失败", + expiresAt: "到期时间", + lastUsed: "最近使用", + tokenStatusActive: "有效", + tokenStatusExpired: "已过期", + tokenStatusRevoked: "已吊销", + revokeToken: "吊销", + revokeConfirm: "确定吊销该 API Key?", + forceChangeTitle: "请修改初始密码", + forceChangeHint: "账号 {{user}} 仍在使用默认密码,登录前必须先修改。", + oldPassword: "当前密码", + confirmPassword: "确认新密码", + savePassword: "保存新密码", + savingPassword: "保存中…", + passwordTooShort: "新密码至少 6 位", + passwordMismatch: "两次输入的新密码不一致", + passwordMustChange: "新密码不能与默认/旧密码相同", + }, collect: { create: { title: "新建采集任务", diff --git a/web/src/index.css b/web/src/index.css index f050d25..94888c6 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -2343,3 +2343,79 @@ pre { min-height: 420px; } } + +.login-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: + radial-gradient(ellipse at 20% 20%, rgba(33, 150, 243, 0.18), transparent 55%), + radial-gradient(ellipse at 80% 0%, rgba(21, 101, 192, 0.2), transparent 45%), + #eef2f7; +} + +.login-card { + width: min(400px, 100%); + padding: 28px 28px 24px; + background: #fff; + border: 1px solid #d8dee8; + border-radius: 10px; + box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08); + display: flex; + flex-direction: column; + gap: 12px; +} + +.login-card__brand { + font-size: 22px; + font-weight: 700; + color: #1565c0; + letter-spacing: 0.02em; +} + +.login-card__title { + margin: 0; + font-size: 20px; + color: #0f172a; +} + +.login-card__hint { + margin: 0; + color: #64748b; + font-size: 13px; +} + +.login-card__label { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 13px; + color: #334155; +} + +.login-card__label input { + height: 36px; + padding: 0 10px; +} + +.login-card__error { + color: #b91c1c; + font-size: 13px; +} + +.login-card__submit { + margin-top: 4px; + height: 38px; + border: 0; + border-radius: 6px; + background: #1565c0; + color: #fff; + font-weight: 600; +} + +.login-card__submit:disabled { + opacity: 0.6; + cursor: not-allowed; +} diff --git a/web/src/layout/AppLayout.tsx b/web/src/layout/AppLayout.tsx index fd5f1de..80bebb6 100644 --- a/web/src/layout/AppLayout.tsx +++ b/web/src/layout/AppLayout.tsx @@ -6,6 +6,7 @@ import { getPageTitleKey, isWorkbenchPath } from "../config/modules"; import { useAppWindowRegistration } from "../hooks/useAppWindowRegistration"; import { useI18n } from "../i18n"; import { returnToWorkbench } from "../utils/workbench"; +import { useAuth } from "../auth/AuthContext"; type ConnLevel = "up" | "down" | "unknown"; @@ -26,6 +27,7 @@ type Props = { export function AppLayout({ connections, children }: Props) { const { t } = useI18n(); const { pathname } = useLocation(); + const { user, logout } = useAuth(); const onWorkbench = isWorkbenchPath(pathname); const pageTitle = t(getPageTitleKey(pathname)); const netxSuffix = @@ -86,6 +88,20 @@ export function AppLayout({ connections, children }: Props) { {t("layout.oclawBridge")}: {connections.oclawBridge} {oclawSuffix} + {user ? ( + + {user.username} + + ) : null} + {user ? ( + + ) : null} diff --git a/web/src/main.tsx b/web/src/main.tsx index 86a256e..1777501 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -7,6 +7,7 @@ import App from "./App.tsx"; import { ErrorBoundary } from "./layout/ErrorBoundary"; import { I18nProvider } from "./i18n"; import { ToastProvider } from "./hooks/useToast"; +import { AuthProvider } from "./auth/AuthContext"; const queryClient = new QueryClient(); @@ -17,7 +18,9 @@ createRoot(document.getElementById("root")!).render( - + + + diff --git a/web/src/pages/ApiTokensPage.tsx b/web/src/pages/ApiTokensPage.tsx new file mode 100644 index 0000000..9bb3de8 --- /dev/null +++ b/web/src/pages/ApiTokensPage.tsx @@ -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 ( +
+

{t("auth.apiKeysTitle")}

+

{t("auth.apiKeysHint")}

+ +
+ setName(e.target.value)} + required + /> + + {isAdmin ? ( + + ) : null} + +
+ + {createdPlain ? ( +
+
{t("auth.tokenOnceHint")}
+ {createdPlain} + +
+ ) : null} + + {tokensQuery.isLoading ?
{t("common.refreshing")}
: null} + + + + + + + + + + + + + + {items.map((row) => ( + + + + + + + + + + ))} + +
{t("auth.tokenName")}{t("auth.tokenOwner")}{t("auth.colTime")}{t("auth.expiresAt")}{t("auth.lastUsed")}{t("auth.status")}{t("auth.actions")}
{row.name}{row.username || row.user_id}{row.created_at || "-"}{row.expires_at || t("auth.expireNever")}{row.last_used_at || "-"} + {row.revoked + ? t("auth.tokenStatusRevoked") + : row.expired + ? t("auth.tokenStatusExpired") + : t("auth.tokenStatusActive")} + + +
+
+ ); +} diff --git a/web/src/pages/AuditPage.tsx b/web/src/pages/AuditPage.tsx new file mode 100644 index 0000000..5ae04f0 --- /dev/null +++ b/web/src/pages/AuditPage.tsx @@ -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; +}; + +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 ( +
+

{t("auth.auditTitle")}

+

{isAdmin ? t("auth.auditHintAdmin") : t("auth.auditHintUser")}

+ +
+ {isAdmin ? ( + { + setPage(1); + setUsername(e.target.value); + }} + /> + ) : null} + { + setPage(1); + setAction(e.target.value); + }} + /> + +
+ + {query.isLoading ?
{t("common.refreshing")}
: null} + + + + + + + + + + + + + + {items.map((row) => ( + + + + + + + + + + ))} + +
{t("auth.colTime")}{t("auth.colUser")}{t("auth.colAction")}{t("auth.colMethod")}{t("auth.colPath")}{t("auth.colStatus")}{t("auth.colIp")}
{row.ts || "-"}{row.actor_username || "-"}{row.action}{row.method}{row.path}{row.status_code}{row.client_ip || "-"}
+ +
+ + + {t("common.pagerMeta", { total, page, pages })} + + +
+
+ ); +} diff --git a/web/src/pages/ForceChangePasswordPage.tsx b/web/src/pages/ForceChangePasswordPage.tsx new file mode 100644 index 0000000..29951bb --- /dev/null +++ b/web/src/pages/ForceChangePasswordPage.tsx @@ -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 ( +
+
void onSubmit(e)}> +
NetX
+

{t("auth.forceChangeTitle")}

+

+ {t("auth.forceChangeHint", { user: user?.username || "admin" })} +

+ + + + {error ?
{error}
: null} + + +
+
+ ); +} diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx new file mode 100644 index 0000000..e5fb67e --- /dev/null +++ b/web/src/pages/LoginPage.tsx @@ -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 ; + } + + 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 ( +
+
void onSubmit(e)}> +
NetX
+

{t("auth.loginTitle")}

+

{t("auth.loginHint")}

+ + + {error ?
{error}
: null} + +
+
+ ); +} diff --git a/web/src/pages/UsersPage.tsx b/web/src/pages/UsersPage.tsx new file mode 100644 index 0000000..fa5d092 --- /dev/null +++ b/web/src/pages/UsersPage.tsx @@ -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>({}); + + 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 }) => + 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 ; + + const onCreate = (e: FormEvent) => { + e.preventDefault(); + createMut.mutate(); + }; + + return ( +
+

{t("auth.usersTitle")}

+

{t("auth.usersHint")}

+ +
+ setUsername(e.target.value)} + required + /> + setPassword(e.target.value)} + required + minLength={6} + /> + + +
+ + {usersQuery.isLoading ?
{t("common.refreshing")}
: null} + + + + + + + + + + + {items.map((u) => ( + + + + + + + ))} + +
{t("auth.username")}{t("auth.role")}{t("auth.status")}{t("auth.actions")}
{u.username}{u.role === "admin" ? t("auth.roleAdmin") : t("auth.roleUser")}{u.is_active ? t("auth.active") : t("auth.disabled")} +
+ + + setResetPwd((m) => ({ ...m, [u.id]: e.target.value }))} + style={{ width: 140 }} + /> + +
+
+
+ ); +} diff --git a/web/src/pages/WorkbenchPage.tsx b/web/src/pages/WorkbenchPage.tsx index 44e7205..b1a76b7 100644 --- a/web/src/pages/WorkbenchPage.tsx +++ b/web/src/pages/WorkbenchPage.tsx @@ -2,11 +2,13 @@ import { useI18n } from "../i18n"; import { WorkbenchCardIcon } from "../components/WorkbenchCardIcon"; import { modulesInSection, type WorkbenchSection } from "../config/modules"; import { openOrFocusModule } from "../utils/moduleWindows"; +import { useAuth } from "../auth/AuthContext"; -const SECTIONS: WorkbenchSection[] = ["monitoring", "operations"]; +const SECTIONS: WorkbenchSection[] = ["monitoring", "operations", "system"]; export function WorkbenchPage() { const { t } = useI18n(); + const { isAdmin } = useAuth(); return (
@@ -14,7 +16,9 @@ export function WorkbenchPage() {

{t(`workbench.${section}`)}

- {modulesInSection(section).map((mod) => ( + {modulesInSection(section) + .filter((mod) => !mod.adminOnly || isAdmin) + .map((mod) => (