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