mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 00:43:17 +08:00
feat(auth): add local login, audit, API keys, and system admin UI
Gate netx Web/API/WebCRT with JWT and per-user API tokens, bootstrap an admin with forced password change, and expose users/audit/API-key management under a System section. MCP can reuse data/auth/mcp_token without extra env for local labs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
14f14d34bd
commit
6d4cd741ef
35 changed files with 2699 additions and 21 deletions
101
netx_api/auth_deps.py
Normal file
101
netx_api/auth_deps.py
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
"""FastAPI dependencies for authenticated / admin-only routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .auth_service import get_user_by_id, resolve_api_token_user
|
||||
from .auth_tokens import decode_access_token
|
||||
from .config import settings
|
||||
from .db import get_db
|
||||
from .models import AppUser
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthContext:
|
||||
user: AppUser
|
||||
auth_via: str # jwt | api_token | disabled
|
||||
|
||||
|
||||
def _extract_bearer(request: Request) -> str:
|
||||
auth = str(request.headers.get("authorization") or "").strip()
|
||||
if auth.lower().startswith("bearer "):
|
||||
return auth[7:].strip()
|
||||
# WebCRT / tools may pass access_token query
|
||||
q = request.query_params.get("access_token")
|
||||
return str(q or "").strip()
|
||||
|
||||
|
||||
def resolve_user_from_token(db: Session, token: str) -> tuple[AppUser, str] | None:
|
||||
raw = str(token or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
if raw.startswith("nxt_"):
|
||||
user = resolve_api_token_user(db, raw)
|
||||
if user is None:
|
||||
return None
|
||||
return user, "api_token"
|
||||
try:
|
||||
payload = decode_access_token(raw)
|
||||
except Exception:
|
||||
return None
|
||||
if str(payload.get("typ") or "") not in ("", "access"):
|
||||
return None
|
||||
user = get_user_by_id(db, str(payload.get("sub") or ""))
|
||||
if user is None or not user.is_active:
|
||||
return None
|
||||
return user, "jwt"
|
||||
|
||||
|
||||
def get_optional_user(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> AuthContext | None:
|
||||
if not bool(settings.auth_enabled):
|
||||
return None
|
||||
token = _extract_bearer(request)
|
||||
if not token:
|
||||
# Middleware may have already attached user
|
||||
cached = getattr(request.state, "auth_user", None)
|
||||
if isinstance(cached, AppUser):
|
||||
via = str(getattr(request.state, "auth_via", "") or "jwt")
|
||||
return AuthContext(user=cached, auth_via=via)
|
||||
return None
|
||||
resolved = resolve_user_from_token(db, token)
|
||||
if resolved is None:
|
||||
return None
|
||||
user, via = resolved
|
||||
request.state.auth_user = user
|
||||
request.state.auth_via = via
|
||||
return AuthContext(user=user, auth_via=via)
|
||||
|
||||
|
||||
def require_user(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> AuthContext:
|
||||
if not bool(settings.auth_enabled):
|
||||
# Auth disabled: synthesize a system principal for Depends callers.
|
||||
fake = AppUser(
|
||||
id="system",
|
||||
username="system",
|
||||
password_hash="",
|
||||
role="admin",
|
||||
is_active=True,
|
||||
created_by="auth_disabled",
|
||||
)
|
||||
return AuthContext(user=fake, auth_via="disabled")
|
||||
ctx = get_optional_user(request, db)
|
||||
if ctx is None:
|
||||
raise HTTPException(status_code=401, detail="unauthorized")
|
||||
return ctx
|
||||
|
||||
|
||||
def require_admin(ctx: Annotated[AuthContext, Depends(require_user)]) -> AuthContext:
|
||||
if ctx.user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="admin_required")
|
||||
return ctx
|
||||
Loading…
Add table
Add a link
Reference in a new issue