netx/netx_api/auth_service.py
oliver 61531e524f Audit only intentional auth actions, not automatic 401/403 gates.
Stop recording unauthorized/forbidden/password-gate and silent refresh; keep login, logout, and failed-login style events.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 21:51:59 +08:00

786 lines
25 KiB
Python

"""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_scopes import (
MCP_DEFAULT_SCOPES,
effective_user_scopes,
normalize_scopes,
)
from .auth_tokens import (
hash_api_token,
issue_access_token,
new_api_token_plaintext,
new_refresh_token_plaintext,
)
from .config import settings
from .models import ApiToken, AppUser, AuditLog, AuthSession
from .timeutil import utcnow_naive
_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",
"refresh_token",
"token",
"authorization",
"secret",
"credential_secret_key",
}
)
def _password_min_len() -> int:
return max(8, int(getattr(settings, "auth_password_min_len", 8) or 8))
def _require_password_strength(pwd: str) -> str:
raw = str(pwd or "")
if len(raw) < _password_min_len():
raise HTTPException(status_code=400, detail="password_too_short")
return raw
def user_public(user: AppUser) -> dict[str, Any]:
scopes = sorted(
effective_user_scopes(role=str(user.role or "user"), override=getattr(user, "scopes", None) or [])
)
return {
"id": user.id,
"username": user.username,
"role": user.role,
"scopes": scopes,
"scopes_override": normalize_scopes(getattr(user, "scopes", None) or []),
"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 = utcnow_naive()
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:
# Ensure MCP bootstrap token stays within MCP_DEFAULT_SCOPES.
desired = normalize_scopes(MCP_DEFAULT_SCOPES)
current = normalize_scopes(getattr(row, "scopes", None) or [])
if current != desired:
row.scopes = desired
try:
db.commit()
except Exception:
db.rollback()
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,
scopes=list(MCP_DEFAULT_SCOPES),
)
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 revoke_auth_sessions(
db: Session,
*,
user_id: str,
except_jti: str | None = None,
only_jti: str | None = None,
) -> int:
"""Revoke JWT sessions. Returns count newly revoked."""
now = utcnow_naive()
q = db.query(AuthSession).filter(
AuthSession.user_id == str(user_id),
AuthSession.revoked_at.is_(None),
)
if only_jti:
q = q.filter(AuthSession.id == str(only_jti))
if except_jti:
q = q.filter(AuthSession.id != str(except_jti))
rows = q.all()
for row in rows:
row.revoked_at = now
if rows:
db.commit()
return len(rows)
def get_auth_session(db: Session, jti: str) -> AuthSession | None:
sid = str(jti or "").strip()
if not sid:
return None
row = db.query(AuthSession).filter(AuthSession.id == sid).first()
if row is None:
return None
if row.revoked_at is not None:
return None
now = utcnow_naive()
exp = row.expires_at
if exp is not None and exp < now:
return None
idle = max(0, int(getattr(settings, "auth_idle_timeout_sec", 7200) or 0))
if idle > 0:
seen = row.last_seen_at or row.created_at
if seen is not None and (now - seen).total_seconds() > idle:
row.revoked_at = now
try:
db.commit()
except Exception:
db.rollback()
return None
return row
def touch_auth_session(db: Session, jti: str) -> None:
row = db.query(AuthSession).filter(AuthSession.id == str(jti)).first()
if row is None or row.revoked_at is not None:
return
row.last_seen_at = utcnow_naive()
try:
db.commit()
except Exception:
db.rollback()
def list_auth_sessions(db: Session, *, user_id: str, current_jti: str = "") -> list[dict[str, Any]]:
now = utcnow_naive()
rows = (
db.query(AuthSession)
.filter(AuthSession.user_id == str(user_id), AuthSession.revoked_at.is_(None))
.order_by(AuthSession.created_at.desc())
.limit(100)
.all()
)
out: list[dict[str, Any]] = []
for row in rows:
if row.expires_at is not None and row.expires_at < now:
continue
refresh_alive = bool(row.refresh_expires_at and row.refresh_expires_at >= now)
access_alive = bool(row.expires_at and row.expires_at >= now)
if not access_alive and not refresh_alive:
continue
out.append(
{
"id": row.id,
"created_at": row.created_at.isoformat() if row.created_at else None,
"expires_at": row.expires_at.isoformat() if row.expires_at else None,
"refresh_expires_at": row.refresh_expires_at.isoformat() if row.refresh_expires_at else None,
"last_seen_at": row.last_seen_at.isoformat() if row.last_seen_at else None,
"client_ip": row.client_ip or "",
"user_agent": (row.user_agent or "")[:200],
"current": bool(current_jti) and row.id == str(current_jti),
}
)
return out
def revoke_auth_session_for_user(
db: Session,
*,
user_id: str,
session_id: str,
current_jti: str = "",
) -> bool:
"""Revoke one session owned by user. Returns True if newly revoked."""
sid = str(session_id or "").strip()
if not sid:
return False
row = (
db.query(AuthSession)
.filter(
AuthSession.id == sid,
AuthSession.user_id == str(user_id),
AuthSession.revoked_at.is_(None),
)
.first()
)
if row is None:
return False
row.revoked_at = utcnow_naive()
db.commit()
return True
def login_issue_token(
db: Session,
user: AppUser,
*,
client_ip: str = "",
user_agent: str = "",
) -> dict[str, Any]:
token, jti, ttl = issue_access_token(
user_id=user.id, username=user.username, role=user.role
)
if bool(getattr(settings, "auth_single_session", False)):
revoke_auth_sessions(db, user_id=str(user.id), except_jti=jti)
refresh_ttl = max(3600, int(getattr(settings, "auth_refresh_ttl_sec", 604800) or 604800))
refresh_plain = new_refresh_token_plaintext()
now = utcnow_naive()
db.add(
AuthSession(
id=jti,
user_id=str(user.id),
created_at=now,
expires_at=now + timedelta(seconds=ttl),
client_ip=str(client_ip or "")[:128],
user_agent=str(user_agent or "")[:512],
last_seen_at=now,
refresh_token_hash=hash_api_token(refresh_plain),
refresh_expires_at=now + timedelta(seconds=refresh_ttl),
)
)
db.commit()
return {
"access_token": token,
"refresh_token": refresh_plain,
"token_type": "bearer",
"expires_in": ttl,
"refresh_expires_in": refresh_ttl,
"user": user_public(user),
}
def refresh_login_tokens(
db: Session,
*,
refresh_token: str,
client_ip: str = "",
user_agent: str = "",
) -> dict[str, Any]:
"""Rotate refresh token and mint a new access JWT (old session revoked)."""
raw = str(refresh_token or "").strip()
if not raw.startswith("nxr_"):
raise HTTPException(status_code=401, detail="invalid_refresh_token")
th = hash_api_token(raw)
now = utcnow_naive()
row = (
db.query(AuthSession)
.filter(AuthSession.refresh_token_hash == th, AuthSession.revoked_at.is_(None))
.first()
)
if row is None:
raise HTTPException(status_code=401, detail="invalid_refresh_token")
refresh_exp = row.refresh_expires_at
if refresh_exp is None or refresh_exp < now:
row.revoked_at = now
db.commit()
raise HTTPException(status_code=401, detail="refresh_token_expired")
user = get_user_by_id(db, str(row.user_id))
if user is None or not user.is_active:
row.revoked_at = now
db.commit()
raise HTTPException(status_code=401, detail="invalid_refresh_token")
row.revoked_at = now
db.commit()
return login_issue_token(db, user, client_ip=client_ip, user_agent=user_agent)
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,
scopes: list[str] | None = None,
) -> AppUser:
name = str(username or "").strip()
if not _USERNAME_RE.match(name):
raise HTTPException(status_code=400, detail="invalid_username")
pwd = _require_password_strength(password)
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")
scope_list = normalize_scopes(scopes) if scopes is not None else []
user = AppUser(
username=name,
password_hash=hash_password(pwd),
role=role_n,
scopes=scope_list,
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,
scopes: list[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
revoke_all = False
if is_active is not None:
user.is_active = bool(is_active)
if not user.is_active:
revoke_all = True
if password is not None:
pwd = _require_password_strength(password)
user.password_hash = hash_password(pwd)
user.must_change_password = True
revoke_all = True
if scopes is not None:
user.scopes = normalize_scopes(scopes)
user.updated_at = utcnow_naive()
db.commit()
db.refresh(user)
if revoke_all:
revoke_auth_sessions(db, user_id=str(user.id))
return user
def change_password(
db: Session,
*,
user: AppUser,
old_password: str,
new_password: str,
keep_jti: str | None = None,
) -> None:
row = get_user_by_id(db, str(user.id)) or user
if not verify_password(old_password, row.password_hash):
raise HTTPException(status_code=400, detail="old_password_incorrect")
pwd = _require_password_strength(new_password)
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")
row.password_hash = hash_password(pwd)
row.must_change_password = False
row.updated_at = utcnow_naive()
db.commit()
# Drop other browser sessions; keep current jti so force-change flow can continue.
revoke_auth_sessions(db, user_id=str(row.id), except_jti=keep_jti)
def create_api_token(
db: Session,
*,
user: AppUser,
name: str,
expires_in_days: int | None = None,
scopes: list[str] | 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 = utcnow_naive() + timedelta(days=int(expires_in_days))
plaintext = new_api_token_plaintext()
scope_list = normalize_scopes(scopes) if scopes is not None else []
# Cap token scopes to owner's effective scopes.
owner_scopes = effective_user_scopes(role=str(user.role or "user"), override=getattr(user, "scopes", None) or [])
if scope_list:
scope_list = sorted(frozenset(scope_list) & owner_scopes)
row = ApiToken(
name=label,
token_hash=hash_api_token(plaintext),
user_id=user.id,
scopes=scope_list,
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 = utcnow_naive()
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 "",
"scopes": normalize_scopes(getattr(r, "scopes", None) or []),
"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 = utcnow_naive()
db.commit()
db.refresh(row)
return row
def update_api_token(
db: Session,
*,
token_id: str,
actor: AppUser,
name: str | None = None,
scopes: list[str] | None = None,
) -> 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 not None:
raise HTTPException(status_code=400, detail="api_token_revoked")
if name is not None:
label = str(name or "").strip() or row.name
if len(label) > 128:
raise HTTPException(status_code=400, detail="token_name_too_long")
row.name = label
if scopes is not None:
owner = get_user_by_id(db, row.user_id)
if owner is None:
raise HTTPException(status_code=404, detail="user_not_found")
owner_scopes = effective_user_scopes(
role=str(owner.role or "user"), override=getattr(owner, "scopes", None) or []
)
scope_list = normalize_scopes(scopes)
if scope_list:
scope_list = sorted(frozenset(scope_list) & owner_scopes)
if not scope_list:
raise HTTPException(status_code=400, detail="scopes_required")
row.scopes = scope_list
db.commit()
db.refresh(row)
return row
def resolve_api_token_row(db: Session, plaintext: str) -> ApiToken | 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 <= utcnow_naive():
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 = utcnow_naive()
try:
db.commit()
except Exception:
db.rollback()
return row
def resolve_api_token_user(db: Session, plaintext: str) -> AppUser | None:
row = resolve_api_token_row(db, plaintext)
if row is None:
return None
return get_user_by_id(db, row.user_id)
def list_audit_logs(
db: Session,
*,
actor: AppUser,
page: int = 1,
page_size: int = 50,
username: str = "",
action: str = "",
exclude_noise: bool = True,
) -> 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()}%"))
if exclude_noise:
# Hide historical HTTP polling + middleware wrappers; keep semantic webcrt.*.
noise_actions = (
"audit.list",
"webcrt.get",
"webcrt.post",
"webcrt.put",
"webcrt.patch",
"webcrt.delete",
"users.get",
"api_tokens.get",
"auth.me",
"auth.sessions",
"auth.refresh",
"auth.unauthorized",
"auth.password_change_required",
"auth.forbidden_scope",
)
q = q.filter(~AuditLog.action.like("http.%"))
q = q.filter(~AuditLog.action.in_(noise_actions))
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,
"exclude_noise": bool(exclude_noise),
}