mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 04:20:45 +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
|
||||
139
netx_api/auth_middleware.py
Normal file
139
netx_api/auth_middleware.py
Normal 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
|
||||
20
netx_api/auth_passwords.py
Normal file
20
netx_api/auth_passwords.py
Normal 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
315
netx_api/auth_router.py
Normal 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
35
netx_api/auth_schemas.py
Normal 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
463
netx_api/auth_service.py
Normal 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
62
netx_api/auth_tokens.py
Normal 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()
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 ''")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue