This commit is contained in:
+159
-7
@@ -1,15 +1,167 @@
|
||||
"""Password hashing, opaque login sessions, API keys, and the auth
|
||||
dependencies used to protect the API.
|
||||
|
||||
There is no shared secret in the environment anymore. Requests authenticate as
|
||||
a specific admin-portal user, either with a login session token
|
||||
(``Authorization: Bearer <token>``, issued by ``POST /auth/login``) or with a
|
||||
programmatic API key (``X-API-Key: <key>``, created by an admin). Both resolve
|
||||
to a row in ``radadmin_admins``; an API key acts as the user who created it.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import Security
|
||||
from fastapi.security import APIKeyHeader
|
||||
from fastapi import Depends, Request, Security
|
||||
from fastapi.security import APIKeyHeader, HTTPBearer
|
||||
from fastapi.security.http import HTTPAuthorizationCredentials
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session as DbSession
|
||||
|
||||
from .config import get_settings
|
||||
from .database import get_db
|
||||
from .errors import APIError
|
||||
from .models import Admin, ApiKey, LogEntry, Session
|
||||
|
||||
# Sessions live this long before a fresh login is required.
|
||||
SESSION_TTL = timedelta(days=7)
|
||||
|
||||
_PBKDF2_ITERATIONS = 240_000
|
||||
|
||||
bearer_scheme = HTTPBearer(auto_error=False)
|
||||
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
||||
|
||||
|
||||
def require_api_key(api_key: str | None = Security(api_key_header)) -> None:
|
||||
expected = get_settings().api_key
|
||||
if not api_key or not secrets.compare_digest(api_key, expected):
|
||||
raise APIError(status_code=401, detail="Invalid or missing API key")
|
||||
# ---------------------------------------------------------------------------
|
||||
# Password + key hashing (PBKDF2-HMAC-SHA256, stdlib only — no native deps)
|
||||
# ---------------------------------------------------------------------------
|
||||
def hash_password(password: str) -> str:
|
||||
"""Return ``pbkdf2_sha256$<iterations>$<salt_hex>$<hash_hex>``."""
|
||||
salt = secrets.token_bytes(16)
|
||||
dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, _PBKDF2_ITERATIONS)
|
||||
return f"pbkdf2_sha256${_PBKDF2_ITERATIONS}${salt.hex()}${dk.hex()}"
|
||||
|
||||
|
||||
def verify_password(password: str, stored: str) -> bool:
|
||||
try:
|
||||
algo, iterations, salt_hex, hash_hex = stored.split("$")
|
||||
if algo != "pbkdf2_sha256":
|
||||
return False
|
||||
dk = hashlib.pbkdf2_hmac(
|
||||
"sha256", password.encode(), bytes.fromhex(salt_hex), int(iterations)
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
return secrets.compare_digest(dk.hex(), hash_hex)
|
||||
|
||||
|
||||
# API keys reuse the same PBKDF2 scheme; the raw key is only shown once.
|
||||
hash_api_key = hash_password
|
||||
verify_api_key = verify_password
|
||||
|
||||
|
||||
def generate_token() -> str:
|
||||
"""A 64-char hex session token (fits ``radadmin_sessions.token CHAR(64)``)."""
|
||||
return secrets.token_hex(32)
|
||||
|
||||
|
||||
def generate_api_key() -> str:
|
||||
"""A user-facing API key, e.g. ``rak_<40 hex chars>``."""
|
||||
return "rak_" + secrets.token_hex(20)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sessions
|
||||
# ---------------------------------------------------------------------------
|
||||
def create_session(db: DbSession, admin: Admin) -> str:
|
||||
token = generate_token()
|
||||
db.add(Session(token=token, admin_id=admin.id, expires_at=datetime.utcnow() + SESSION_TTL))
|
||||
db.commit()
|
||||
return token
|
||||
|
||||
|
||||
def _admin_from_bearer(db: DbSession, token: str) -> Admin | None:
|
||||
session = db.get(Session, token)
|
||||
if session is None:
|
||||
return None
|
||||
if session.expires_at < datetime.utcnow():
|
||||
db.delete(session)
|
||||
db.commit()
|
||||
return None
|
||||
return db.get(Admin, session.admin_id)
|
||||
|
||||
|
||||
# A synthetic principal for API keys whose creator no longer exists. It can act
|
||||
# on resources but is not a real DB user, so it can't manage users/keys/logs.
|
||||
_SYSTEM_ADMIN = Admin(id=0, username="api-key", is_admin=False, must_change_password=False)
|
||||
|
||||
|
||||
def _admin_from_api_key(db: DbSession, raw_key: str) -> Admin | None:
|
||||
prefix = raw_key[:8]
|
||||
rows = (
|
||||
db.execute(select(ApiKey).where(ApiKey.key_prefix == prefix, ApiKey.revoked.is_(False)))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
for row in rows:
|
||||
if verify_api_key(raw_key, row.key_hash):
|
||||
row.last_used_at = datetime.utcnow()
|
||||
db.commit()
|
||||
creator = db.execute(
|
||||
select(Admin).where(Admin.username == row.created_by)
|
||||
).scalar_one_or_none()
|
||||
return creator or _SYSTEM_ADMIN
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dependencies
|
||||
# ---------------------------------------------------------------------------
|
||||
def get_current_admin(
|
||||
request: Request,
|
||||
bearer: HTTPAuthorizationCredentials | None = Security(bearer_scheme),
|
||||
api_key: str | None = Security(api_key_header),
|
||||
db: DbSession = Depends(get_db),
|
||||
) -> Admin:
|
||||
"""Resolve the caller from a Bearer session token or an X-API-Key.
|
||||
|
||||
Stashes the resolved username on ``request.state`` so the activity-log
|
||||
middleware can attribute the request without re-parsing credentials.
|
||||
"""
|
||||
admin: Admin | None = None
|
||||
if bearer and bearer.credentials:
|
||||
admin = _admin_from_bearer(db, bearer.credentials)
|
||||
if admin is None and api_key:
|
||||
admin = _admin_from_api_key(db, api_key)
|
||||
if admin is None:
|
||||
raise APIError(status_code=401, detail="Invalid or missing credentials")
|
||||
request.state.username = admin.username
|
||||
return admin
|
||||
|
||||
|
||||
def require_admin(admin: Admin = Depends(get_current_admin)) -> Admin:
|
||||
"""Guard for endpoints only full admins may call."""
|
||||
if not admin.is_admin:
|
||||
raise APIError(status_code=403, detail="Administrator privileges required")
|
||||
return admin
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Activity log helper
|
||||
# ---------------------------------------------------------------------------
|
||||
def record_log(
|
||||
db: DbSession,
|
||||
*,
|
||||
username: str | None,
|
||||
action: str,
|
||||
detail: str | None = None,
|
||||
request: Request | None = None,
|
||||
) -> None:
|
||||
"""Insert one activity-log row. Never raises into the caller."""
|
||||
ip = None
|
||||
if request is not None and request.client is not None:
|
||||
ip = request.client.host
|
||||
try:
|
||||
db.add(LogEntry(username=username, action=action, detail=detail, ip_address=ip))
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""First-run seeding. Creates the default ``admin`` / ``admin`` account when
|
||||
``radadmin_admins`` is empty, forcing a password change on first login.
|
||||
Idempotent: once any admin exists this does nothing.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from . import auth
|
||||
from .database import SessionLocal
|
||||
from .models import Admin
|
||||
|
||||
log = logging.getLogger("uvicorn.error")
|
||||
|
||||
DEFAULT_USERNAME = "admin"
|
||||
DEFAULT_PASSWORD = "admin"
|
||||
|
||||
|
||||
def seed_default_admin() -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
count = db.execute(select(func.count()).select_from(Admin)).scalar_one()
|
||||
if count:
|
||||
return
|
||||
db.add(
|
||||
Admin(
|
||||
username=DEFAULT_USERNAME,
|
||||
password_hash=auth.hash_password(DEFAULT_PASSWORD),
|
||||
is_admin=True,
|
||||
must_change_password=True,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
log.warning(
|
||||
"Seeded default admin account '%s' / '%s' — change this password on first login.",
|
||||
DEFAULT_USERNAME,
|
||||
DEFAULT_PASSWORD,
|
||||
)
|
||||
except Exception as exc: # never block startup on a seeding hiccup
|
||||
db.rollback()
|
||||
log.error("Could not seed default admin: %s", exc)
|
||||
finally:
|
||||
db.close()
|
||||
@@ -12,7 +12,6 @@ class Settings(BaseSettings):
|
||||
db_password: str = ""
|
||||
db_name: str = "radius"
|
||||
|
||||
api_key: str = "change-me"
|
||||
cors_origins: str = "*"
|
||||
default_limit: int = 50
|
||||
max_limit: int = 500
|
||||
|
||||
+60
-8
@@ -1,15 +1,21 @@
|
||||
from fastapi import Depends, FastAPI
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import Depends, FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy import text
|
||||
|
||||
from .auth import require_api_key
|
||||
from .auth import get_current_admin, record_log
|
||||
from .bootstrap import seed_default_admin
|
||||
from .config import get_settings
|
||||
from .database import engine
|
||||
from .database import SessionLocal, engine
|
||||
from .errors import APIError, api_error_handler
|
||||
from .routers import (
|
||||
apikeys,
|
||||
auth,
|
||||
client,
|
||||
customers,
|
||||
device,
|
||||
logs,
|
||||
nas,
|
||||
nasreload,
|
||||
radacct,
|
||||
@@ -19,16 +25,26 @@ from .routers import (
|
||||
radpostauth,
|
||||
radreply,
|
||||
radusergroup,
|
||||
users,
|
||||
vlan,
|
||||
)
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Seed the default admin/admin account on first run.
|
||||
seed_default_admin()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="FreeRADIUS REST API",
|
||||
description="RESTful CRUD over the FreeRADIUS SQL schema. "
|
||||
"All endpoints require the `X-API-Key` header.",
|
||||
version="1.0.0",
|
||||
description="RESTful CRUD over the FreeRADIUS SQL schema. Authenticate with a "
|
||||
"login session (`Authorization: Bearer`) or an admin-issued `X-API-Key`.",
|
||||
version="2.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
@@ -42,6 +58,35 @@ app.add_middleware(
|
||||
app.add_exception_handler(APIError, api_error_handler)
|
||||
|
||||
|
||||
# Management routers self-log with rich detail; skip them here to avoid dupes.
|
||||
_SELF_LOGGED_PREFIXES = ("/auth", "/users", "/apikeys")
|
||||
_LOGGED_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def activity_log_middleware(request: Request, call_next):
|
||||
"""Record every successful state-changing request to radadmin_logs, attributed
|
||||
to the user resolved by the auth dependency (stashed on request.state)."""
|
||||
response = await call_next(request)
|
||||
if request.method in _LOGGED_METHODS and response.status_code < 400:
|
||||
path = request.url.path
|
||||
if not path.startswith(_SELF_LOGGED_PREFIXES):
|
||||
username = getattr(request.state, "username", None)
|
||||
if username:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
record_log(
|
||||
db,
|
||||
username=username,
|
||||
action=request.method.lower(),
|
||||
detail=f"{request.method} {path}",
|
||||
request=request,
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
return response
|
||||
|
||||
|
||||
# ---- unauthenticated meta endpoints ----
|
||||
@app.get("/", tags=["meta"])
|
||||
def root():
|
||||
@@ -59,8 +104,15 @@ def health():
|
||||
return {"status": "ok" if db_ok else "degraded", "database": "up" if db_ok else "down"}
|
||||
|
||||
|
||||
# ---- authenticated resource routers ----
|
||||
protected = [Depends(require_api_key)]
|
||||
# ---- auth + management routers (guard themselves per-endpoint) ----
|
||||
app.include_router(auth.router)
|
||||
app.include_router(users.router)
|
||||
app.include_router(apikeys.router)
|
||||
app.include_router(logs.router)
|
||||
|
||||
|
||||
# ---- authenticated resource routers (any logged-in user or API key) ----
|
||||
protected = [Depends(get_current_admin)]
|
||||
for module in (
|
||||
customers,
|
||||
nas,
|
||||
|
||||
+61
-1
@@ -1,11 +1,71 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, DateTime, Integer, String, func
|
||||
from sqlalchemy import BigInteger, Boolean, DateTime, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .database import Base
|
||||
|
||||
|
||||
class Admin(Base):
|
||||
"""An admin-portal login. `is_admin` grants user/API-key/log management."""
|
||||
|
||||
__tablename__ = "radadmin_admins"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
username: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
is_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
must_change_password: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
created_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime, nullable=True, server_default=func.current_timestamp()
|
||||
)
|
||||
|
||||
|
||||
class Session(Base):
|
||||
"""An opaque login token handed to the browser (Authorization: Bearer)."""
|
||||
|
||||
__tablename__ = "radadmin_sessions"
|
||||
|
||||
token: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
admin_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
created_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime, nullable=True, server_default=func.current_timestamp()
|
||||
)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
|
||||
|
||||
class ApiKey(Base):
|
||||
"""A programmatic key (X-API-Key). Only the hash is stored."""
|
||||
|
||||
__tablename__ = "radadmin_api_keys"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
key_prefix: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
key_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
created_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime, nullable=True, server_default=func.current_timestamp()
|
||||
)
|
||||
last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
revoked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
|
||||
class LogEntry(Base):
|
||||
"""One activity-log row per state-changing action or auth event."""
|
||||
|
||||
__tablename__ = "radadmin_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
username: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
action: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
detail: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)
|
||||
created_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime, nullable=True, server_default=func.current_timestamp()
|
||||
)
|
||||
|
||||
|
||||
class Customer(Base):
|
||||
__tablename__ = "customers"
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Programmatic API keys. Only admins may create, list, or revoke keys.
|
||||
|
||||
A key authenticates via the ``X-API-Key`` header and acts as the admin who
|
||||
created it. The raw key is returned exactly once, at creation; afterwards only
|
||||
its prefix and hash are stored.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session as DbSession
|
||||
|
||||
from .. import auth
|
||||
from ..auth import require_admin
|
||||
from ..database import get_db
|
||||
from ..errors import APIError
|
||||
from ..models import Admin, ApiKey
|
||||
from ..schemas import ApiKeyCreate, ApiKeyCreated, ApiKeyOut
|
||||
|
||||
router = APIRouter(prefix="/apikeys", tags=["apikeys"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[ApiKeyOut])
|
||||
def list_api_keys(_: Admin = Depends(require_admin), db: DbSession = Depends(get_db)):
|
||||
return db.execute(select(ApiKey).order_by(ApiKey.id.desc())).scalars().all()
|
||||
|
||||
|
||||
@router.post("", response_model=ApiKeyCreated, status_code=201)
|
||||
def create_api_key(
|
||||
payload: ApiKeyCreate,
|
||||
request: Request,
|
||||
admin: Admin = Depends(require_admin),
|
||||
db: DbSession = Depends(get_db),
|
||||
):
|
||||
raw = auth.generate_api_key()
|
||||
key = ApiKey(
|
||||
name=payload.name,
|
||||
key_prefix=raw[:8],
|
||||
key_hash=auth.hash_api_key(raw),
|
||||
created_by=admin.username,
|
||||
)
|
||||
db.add(key)
|
||||
db.commit()
|
||||
db.refresh(key)
|
||||
auth.record_log(
|
||||
db,
|
||||
username=admin.username,
|
||||
action="create_api_key",
|
||||
detail=f"Created API key '{key.name}' ({key.key_prefix}…)",
|
||||
request=request,
|
||||
)
|
||||
return ApiKeyCreated(**ApiKeyOut.model_validate(key).model_dump(), key=raw)
|
||||
|
||||
|
||||
@router.delete("/{key_id}", status_code=204)
|
||||
def revoke_api_key(
|
||||
key_id: int,
|
||||
request: Request,
|
||||
admin: Admin = Depends(require_admin),
|
||||
db: DbSession = Depends(get_db),
|
||||
):
|
||||
key = db.get(ApiKey, key_id)
|
||||
if key is None:
|
||||
raise APIError(status_code=404, detail=f"API key '{key_id}' not found")
|
||||
key.revoked = True
|
||||
db.commit()
|
||||
auth.record_log(
|
||||
db,
|
||||
username=admin.username,
|
||||
action="revoke_api_key",
|
||||
detail=f"Revoked API key '{key.name}' ({key.key_prefix}…)",
|
||||
request=request,
|
||||
)
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Login, logout, current-user and self-service password change.
|
||||
|
||||
``POST /auth/login`` is the only unauthenticated endpoint here; the rest resolve
|
||||
the caller from their Bearer session token.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, Security
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session as DbSession
|
||||
|
||||
from .. import auth
|
||||
from ..auth import bearer_scheme, get_current_admin
|
||||
from ..database import get_db
|
||||
from ..errors import APIError
|
||||
from ..models import Admin, Session
|
||||
from ..schemas import (
|
||||
ChangePasswordRequest,
|
||||
CurrentUser,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
def login(payload: LoginRequest, request: Request, db: DbSession = Depends(get_db)):
|
||||
admin = db.execute(
|
||||
select(Admin).where(Admin.username == payload.username)
|
||||
).scalar_one_or_none()
|
||||
if admin is None or not auth.verify_password(payload.password, admin.password_hash):
|
||||
# Generic message so we don't leak which usernames exist.
|
||||
raise APIError(status_code=401, detail="Invalid username or password")
|
||||
|
||||
token = auth.create_session(db, admin)
|
||||
auth.record_log(
|
||||
db, username=admin.username, action="login", detail="Signed in", request=request
|
||||
)
|
||||
return LoginResponse(
|
||||
token=token,
|
||||
username=admin.username,
|
||||
is_admin=admin.is_admin,
|
||||
must_change_password=admin.must_change_password,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/logout", status_code=204)
|
||||
def logout(
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
creds=Security(bearer_scheme),
|
||||
db: DbSession = Depends(get_db),
|
||||
):
|
||||
if creds and creds.credentials:
|
||||
session = db.get(Session, creds.credentials)
|
||||
if session is not None:
|
||||
db.delete(session)
|
||||
db.commit()
|
||||
auth.record_log(db, username=admin.username, action="logout", detail="Signed out", request=request)
|
||||
|
||||
|
||||
@router.get("/me", response_model=CurrentUser)
|
||||
def me(admin: Admin = Depends(get_current_admin)):
|
||||
return admin
|
||||
|
||||
|
||||
@router.post("/change-password", status_code=204)
|
||||
def change_password(
|
||||
payload: ChangePasswordRequest,
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: DbSession = Depends(get_db),
|
||||
):
|
||||
"""Change your OWN password. Any authenticated user may do this.
|
||||
|
||||
When the account is flagged ``must_change_password`` (a forced first-login or
|
||||
post-reset change) the current password is not required — the user just
|
||||
authenticated with it. A normal self-service change still verifies it.
|
||||
"""
|
||||
if not admin.must_change_password:
|
||||
if not payload.current_password or not auth.verify_password(
|
||||
payload.current_password, admin.password_hash
|
||||
):
|
||||
raise APIError(status_code=400, detail="Current password is incorrect")
|
||||
admin.password_hash = auth.hash_password(payload.new_password)
|
||||
admin.must_change_password = False
|
||||
db.commit()
|
||||
auth.record_log(
|
||||
db,
|
||||
username=admin.username,
|
||||
action="change_password",
|
||||
detail="Changed own password",
|
||||
request=request,
|
||||
)
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Read-only activity log. Admin-only: shows every state-changing action and
|
||||
auth event, filterable by acting user."""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session as DbSession
|
||||
|
||||
from ..auth import require_admin
|
||||
from ..database import get_db
|
||||
from ..models import Admin, LogEntry
|
||||
from ..pagination import Page, PageParams
|
||||
from ..schemas import LogOut
|
||||
|
||||
router = APIRouter(prefix="/logs", tags=["logs"])
|
||||
|
||||
|
||||
@router.get("", response_model=Page[LogOut])
|
||||
def list_logs(
|
||||
page: PageParams = Depends(),
|
||||
username: str | None = None,
|
||||
action: str | None = None,
|
||||
_: Admin = Depends(require_admin),
|
||||
db: DbSession = Depends(get_db),
|
||||
):
|
||||
stmt = select(LogEntry)
|
||||
count_stmt = select(func.count()).select_from(LogEntry)
|
||||
if username:
|
||||
stmt = stmt.where(LogEntry.username == username)
|
||||
count_stmt = count_stmt.where(LogEntry.username == username)
|
||||
if action:
|
||||
stmt = stmt.where(LogEntry.action == action)
|
||||
count_stmt = count_stmt.where(LogEntry.action == action)
|
||||
|
||||
total = int(db.execute(count_stmt).scalar_one())
|
||||
rows = db.execute(
|
||||
stmt.order_by(LogEntry.id.desc()).limit(page.limit).offset(page.offset)
|
||||
).scalars().all()
|
||||
return Page(total=total, limit=page.limit, offset=page.offset, items=rows)
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Admin-portal user management. Every endpoint requires an admin — regular
|
||||
users cannot create, delete, or reset the password of any account (they can
|
||||
only change their own password via ``/auth/change-password``).
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session as DbSession
|
||||
|
||||
from .. import auth
|
||||
from ..auth import require_admin
|
||||
from ..database import get_db
|
||||
from ..errors import APIError
|
||||
from ..models import Admin, Session
|
||||
from ..schemas import AdminCreate, AdminOut, ResetPasswordRequest
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[AdminOut])
|
||||
def list_users(_: Admin = Depends(require_admin), db: DbSession = Depends(get_db)):
|
||||
return db.execute(select(Admin).order_by(Admin.id)).scalars().all()
|
||||
|
||||
|
||||
@router.post("", response_model=AdminOut, status_code=201)
|
||||
def create_user(
|
||||
payload: AdminCreate,
|
||||
request: Request,
|
||||
admin: Admin = Depends(require_admin),
|
||||
db: DbSession = Depends(get_db),
|
||||
):
|
||||
exists = db.execute(
|
||||
select(Admin.id).where(Admin.username == payload.username)
|
||||
).scalar_one_or_none()
|
||||
if exists is not None:
|
||||
raise APIError(status_code=409, detail=f"User '{payload.username}' already exists")
|
||||
|
||||
user = Admin(
|
||||
username=payload.username,
|
||||
password_hash=auth.hash_password(payload.password),
|
||||
is_admin=payload.is_admin,
|
||||
must_change_password=payload.force_password_change,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
auth.record_log(
|
||||
db,
|
||||
username=admin.username,
|
||||
action="create_user",
|
||||
detail=f"Created user '{user.username}'" + (" (admin)" if user.is_admin else ""),
|
||||
request=request,
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
@router.delete("/{user_id}", status_code=204)
|
||||
def delete_user(
|
||||
user_id: int,
|
||||
request: Request,
|
||||
admin: Admin = Depends(require_admin),
|
||||
db: DbSession = Depends(get_db),
|
||||
):
|
||||
if user_id == admin.id:
|
||||
raise APIError(status_code=400, detail="You cannot delete your own account")
|
||||
user = db.get(Admin, user_id)
|
||||
if user is None:
|
||||
raise APIError(status_code=404, detail=f"User '{user_id}' not found")
|
||||
if user.is_admin:
|
||||
admin_count = db.execute(
|
||||
select(func.count()).select_from(Admin).where(Admin.is_admin.is_(True))
|
||||
).scalar_one()
|
||||
if admin_count <= 1:
|
||||
raise APIError(status_code=400, detail="Cannot delete the last administrator")
|
||||
|
||||
# Drop the user's sessions too so any live token stops working immediately.
|
||||
for s in db.execute(select(Session).where(Session.admin_id == user.id)).scalars().all():
|
||||
db.delete(s)
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
auth.record_log(
|
||||
db,
|
||||
username=admin.username,
|
||||
action="delete_user",
|
||||
detail=f"Deleted user '{user.username}'",
|
||||
request=request,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{user_id}/reset-password", status_code=204)
|
||||
def reset_password(
|
||||
user_id: int,
|
||||
payload: ResetPasswordRequest,
|
||||
request: Request,
|
||||
admin: Admin = Depends(require_admin),
|
||||
db: DbSession = Depends(get_db),
|
||||
):
|
||||
"""Admin resets another user's password; they must change it on next login."""
|
||||
user = db.get(Admin, user_id)
|
||||
if user is None:
|
||||
raise APIError(status_code=404, detail=f"User '{user_id}' not found")
|
||||
user.password_hash = auth.hash_password(payload.new_password)
|
||||
user.must_change_password = payload.force_password_change
|
||||
# Invalidate existing sessions so the old password/session can't be reused.
|
||||
for s in db.execute(select(Session).where(Session.admin_id == user.id)).scalars().all():
|
||||
db.delete(s)
|
||||
db.commit()
|
||||
auth.record_log(
|
||||
db,
|
||||
username=admin.username,
|
||||
action="reset_password",
|
||||
detail=f"Reset password for '{user.username}'",
|
||||
request=request,
|
||||
)
|
||||
@@ -9,6 +9,84 @@ class ORMModel(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ---------- auth: login / session / current user ----------
|
||||
class LoginRequest(BaseModel):
|
||||
username: str = Field(max_length=64)
|
||||
password: str = Field(min_length=1, max_length=256)
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
token: str
|
||||
username: str
|
||||
is_admin: bool
|
||||
must_change_password: bool
|
||||
|
||||
|
||||
class CurrentUser(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
is_admin: bool
|
||||
must_change_password: bool
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
# Optional: a forced first-login change skips it (the user just authenticated
|
||||
# with their current password to reach that screen). A normal self-service
|
||||
# change from the account page still requires it.
|
||||
current_password: str | None = Field(default=None, max_length=256)
|
||||
new_password: str = Field(min_length=6, max_length=256)
|
||||
|
||||
|
||||
# ---------- admin users (radadmin_admins) ----------
|
||||
class AdminOut(ORMModel):
|
||||
id: int
|
||||
username: str
|
||||
is_admin: bool
|
||||
must_change_password: bool
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class AdminCreate(BaseModel):
|
||||
username: str = Field(min_length=1, max_length=64)
|
||||
password: str = Field(min_length=6, max_length=256)
|
||||
is_admin: bool = False
|
||||
force_password_change: bool = True # require a reset on first login
|
||||
|
||||
|
||||
class ResetPasswordRequest(BaseModel):
|
||||
new_password: str = Field(min_length=6, max_length=256)
|
||||
force_password_change: bool = True # make the user reset again on next login
|
||||
|
||||
|
||||
# ---------- API keys (radadmin_api_keys) ----------
|
||||
class ApiKeyOut(ORMModel):
|
||||
id: int
|
||||
name: str
|
||||
key_prefix: str
|
||||
created_by: str | None = None
|
||||
created_at: datetime | None = None
|
||||
last_used_at: datetime | None = None
|
||||
revoked: bool
|
||||
|
||||
|
||||
class ApiKeyCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=64)
|
||||
|
||||
|
||||
class ApiKeyCreated(ApiKeyOut):
|
||||
key: str # the raw key, shown only once at creation
|
||||
|
||||
|
||||
# ---------- activity log (radadmin_logs) ----------
|
||||
class LogOut(ORMModel):
|
||||
id: int
|
||||
username: str | None = None
|
||||
action: str
|
||||
detail: str | None = None
|
||||
ip_address: str | None = None
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
# ---------- customers ----------
|
||||
# Human metadata (name/phone/alias) lives in radadmin_clients now — customers holds
|
||||
# only the RADIUS-adjacent identity + billing status.
|
||||
|
||||
Reference in New Issue
Block a user