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

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

View file

@ -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 ''")

View file

@ -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)

View file

@ -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: