added multi user support, move api key to database
build-and-push / build (push) Failing after 35s

This commit is contained in:
2026-08-01 13:16:54 +05:00
parent a7c5fe739a
commit 66073c7891
23 changed files with 2123 additions and 103 deletions
+159 -7
View File
@@ -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()
+44
View File
@@ -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()
-1
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+72
View File
@@ -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,
)
+94
View File
@@ -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,
)
+38
View File
@@ -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)
+114
View File
@@ -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,
)
+78
View File
@@ -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.