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
+3 -2
View File
@@ -6,8 +6,9 @@ DB_PASSWORD=changeme
DB_NAME=radius
# --- API ---
# Shared secret required in the X-API-Key header on every request.
API_KEY=change-this-to-a-long-random-string
# No shared API secret anymore. Auth is per-user: the API seeds a default
# admin/admin login on first run (must change password on first sign-in), and
# admins mint revocable X-API-Key values from the web UI for programmatic use.
# Comma-separated CORS origins (use * only for trusted/dev networks)
CORS_ORIGINS=*
+106 -15
View File
@@ -19,16 +19,21 @@ The two `radadmin_*` tables hold only human labels — RADIUS never reads them.
- **FastAPI** + **Uvicorn** (ASGI)
- **SQLAlchemy 2.0** ORM + **PyMySQL** driver
- **Pydantic v2** request/response validation
- Auth via a shared secret in the **`X-API-Key`** header
- Per-user auth: username/password **login sessions** (`Authorization: Bearer`)
plus admin-issued **`X-API-Key`** keys for programmatic access
## Setup
```bash
python3 -m venv venv
venv/bin/pip install -r requirements.txt
cp .env.example .env # then edit credentials + API_KEY
cp .env.example .env # then edit DB credentials
```
Import `radadmin_schema.sql` (after the FreeRADIUS `schema.sql`) so the auth
tables exist. On first startup the API seeds a default **`admin` / `admin`**
login and forces a password change on first sign-in.
### `.env`
| Var | Meaning |
@@ -37,7 +42,6 @@ cp .env.example .env # then edit credentials + API_KEY
| `DB_PORT` | MySQL port (default 3306) |
| `DB_USER` / `DB_PASSWORD` | DB credentials |
| `DB_NAME` | Database name (`radius`) |
| `API_KEY` | Shared secret required in `X-API-Key` |
| `CORS_ORIGINS` | Comma-separated allowed origins (`*` for dev) |
| `DEFAULT_LIMIT` / `MAX_LIMIT` | List pagination caps |
@@ -58,13 +62,63 @@ venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000
## Auth
Every resource endpoint requires the header:
Auth is **per-user**, not a shared secret. There are two ways to authenticate a
request, both resolving to a user in `radadmin_admins`:
```
X-API-Key: <your API_KEY>
```
1. **Login session** (the web UI) — `POST /auth/login` with `{username, password}`
returns a `token`. Send it on every request as:
Missing/wrong key → `401`. `/`, `/health`, and `/docs` are open.
```
Authorization: Bearer <token>
```
2. **API key** (scripts / integrations) — an **admin** mints a key in the UI
(`POST /apikeys`). The raw key is shown once; send it as:
```
X-API-Key: <key>
```
Missing/invalid credentials → `401`. An action that needs admin rights when
you're a regular user → `403`. `/`, `/health`, `/docs`, and `POST /auth/login`
are open.
### Roles
- **admin** (`is_admin = 1`) — everything below, plus manage users, mint/revoke
API keys, and read the activity log.
- **user** (regular) — full access to the RADIUS resources (clients, VLANs,
devices, …) and can change **their own** password, but cannot create/delete
users, reset anyone else's password, manage API keys, or view the log.
### First deploy
The database seeds `admin` / `admin` on first run with a forced password change.
Sign in, change the password when prompted, then create the real users.
### Auth & admin endpoints
| Path | Method | Who | Purpose |
|-------------------------------|--------|-------|-------------------------------------------|
| `/auth/login` | POST | open | `{username,password}` → `{token, username, is_admin, must_change_password}` |
| `/auth/logout` | POST | any | Invalidate the current session token |
| `/auth/me` | GET | any | Current user `{id, username, is_admin, must_change_password}` |
| `/auth/change-password` | POST | any | Change **your own** password `{current_password, new_password}` |
| `/users` | GET | admin | List admin-portal users |
| `/users` | POST | admin | Create a user `{username, password, is_admin, force_password_change}` (`force_password_change` defaults to `true`) |
| `/users/{id}` | DELETE | admin | Delete a user (not self, not last admin) |
| `/users/{id}/reset-password` | POST | admin | Reset another user's password `{new_password}` |
| `/apikeys` | GET | admin | List API keys (hashes never returned) |
| `/apikeys` | POST | admin | Create a key `{name}` → response includes raw `key` once |
| `/apikeys/{id}` | DELETE | admin | Revoke a key |
| `/logs` | GET | admin | Paginated activity log (`?username=&action=`) |
### Activity log
Every **state-changing** request (`POST`/`PUT`/`PATCH`/`DELETE`) and every auth
event (login, logout, password change, user & key management) is recorded to
`radadmin_logs` with the acting username, action, a detail string, the client IP,
and a timestamp. Admins read it at `GET /logs`.
## UI integration guide
@@ -73,7 +127,9 @@ For a management portal the primary resources are **Clients** (`/client`),
raw-table access.
**Base URL (staging):** `http://10.0.1.235:8000`
**Every request:** header `X-API-Key: <API_KEY>` (except `/health`).
**Every request:** an auth header — `Authorization: Bearer <token>` from
`POST /auth/login`, or `X-API-Key: <key>` for an admin-issued key (except
`/health` and `/auth/login`).
**All bodies:** JSON with `Content-Type: application/json`.
### Response shapes
@@ -254,10 +310,39 @@ standalone **`radadmin_devices`** table (keyed by AP MAC), which FreeRADIUS neve
## Examples
Replace host/key to match your deployment.
Replace host to match your deployment. The `X-API-Key` header below stands for an
**admin-issued key** — or swap any of these for `-H "Authorization: Bearer $TOKEN"`
using a token from `/auth/login`.
```bash
# List customers
# Log in (default seeded creds on first deploy: admin / admin)
curl http://10.0.1.235:8000/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin"}' -s | jq
# Change your own password (required after the first login)
curl http://10.0.1.235:8000/auth/change-password \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"current_password":"admin","new_password":"a-better-password"}' -s | jq
# Create a user (admin only)
curl http://10.0.1.235:8000/users \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"username":"ops","password":"changeme","is_admin":false}' -s | jq
# Mint an API key (admin only) — the raw "key" is shown once in the response
curl http://10.0.1.235:8000/apikeys \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"billing-cron"}' -s | jq
# Read the activity log (admin only)
curl http://10.0.1.235:8000/logs?limit=50 \
-H "Authorization: Bearer $TOKEN" -s | jq
# List customers (using an admin-issued API key)
curl http://10.0.1.235:8000/customers?limit=50 \
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" -s | jq
@@ -349,7 +434,8 @@ curl http://10.0.1.235:8000/health -s | jq
| 201 | Created |
| 204 | Deleted (no content) |
| 400 | Bad request (e.g. referenced group/VLAN doesn't exist) |
| 401 | Missing/invalid `X-API-Key` |
| 401 | Missing/invalid credentials (Bearer token or API key) |
| 403 | Authenticated but not an admin for an admin-only action |
| 404 | Row / client / device / VLAN not found |
| 409 | Duplicate / integrity conflict |
| 422 | Request body failed validation |
@@ -358,16 +444,21 @@ curl http://10.0.1.235:8000/health -s | jq
```
app/
main.py FastAPI app, router wiring, auth + CORS
main.py FastAPI app, router wiring, activity-log middleware, CORS
config.py env-driven settings (pydantic-settings)
database.py SQLAlchemy engine/session
auth.py X-API-Key dependency
auth.py password hashing, sessions, API keys, auth dependencies
bootstrap.py first-run seeding of the default admin/admin account
errors.py APIError + JSON handler
crud.py generic list/get/create/update/delete helpers
pagination.py Page envelope + limit/offset dependency
models.py SQLAlchemy models (incl. radadmin_clients, radadmin_devices)
models.py SQLAlchemy models (incl. radadmin_admins/sessions/api_keys/logs)
schemas.py Pydantic request/response models
routers/ one module per resource
auth.py login / logout / me / change-password
users.py admin user management (create/delete/reset-password)
apikeys.py admin API-key management (create/list/revoke)
logs.py admin activity-log view
client.py Clients — MAC view over radcheck+radusergroup+customers
device.py Devices — AP MACs from radacct + radadmin_devices alias
vlan.py VLANs — view over radgroupreply
+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.
+80
View File
@@ -31,3 +31,83 @@ CREATE TABLE IF NOT EXISTS radadmin_devices (
PRIMARY KEY (id),
UNIQUE KEY uq_radadmin_devices_mac (mac_address)
);
-- ---------------------------------------------------------------------------
-- Admin portal auth. Replaces the old single shared X-API-Key env secret with
-- real per-user logins, roles, revocable API keys, and an activity log.
--
-- The API seeds a default "admin" / "admin" account on first startup when
-- radadmin_admins is empty (must_change_password = 1 forces a reset on the
-- first login). No password hashes are stored in this file so the seed always
-- matches the app's hashing scheme.
-- ---------------------------------------------------------------------------
-- Admin portal users. password_hash is PBKDF2-HMAC-SHA256, formatted as
-- "pbkdf2_sha256$<iterations>$<salt_hex>$<hash_hex>". is_admin grants user
-- management, API-key management and activity-log access.
CREATE TABLE IF NOT EXISTS radadmin_admins (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
username VARCHAR(64) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
is_admin TINYINT(1) NOT NULL DEFAULT 0,
must_change_password TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_radadmin_admins_username (username)
);
-- Seed the default admin/admin login at import time so it exists regardless of
-- whether the API has started yet. INSERT IGNORE makes this idempotent: it is
-- skipped once an 'admin' row exists, so it never clobbers a changed password.
-- The hash below is PBKDF2-HMAC-SHA256 of "admin"; must_change_password = 1
-- forces a reset on first sign-in. (The API also seeds this on first startup.)
INSERT IGNORE INTO radadmin_admins (username, password_hash, is_admin, must_change_password)
VALUES (
'admin',
'pbkdf2_sha256$240000$b03a4d5fdc007ba832d5302dc787666d$f1c1a75c58af12402c2c9f2517ca2a89ab13eb5b8613929fb60f408559e48e79',
1, 1
);
-- Opaque login sessions. token is a random secret handed to the browser and
-- sent back as "Authorization: Bearer <token>". Rows are deleted on logout and
-- ignored/cleaned once expires_at passes.
CREATE TABLE IF NOT EXISTS radadmin_sessions (
token CHAR(64) NOT NULL,
admin_id INT UNSIGNED NOT NULL,
created_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL,
PRIMARY KEY (token),
KEY idx_radadmin_sessions_admin (admin_id),
CONSTRAINT fk_radadmin_sessions_admin
FOREIGN KEY (admin_id) REFERENCES radadmin_admins (id) ON DELETE CASCADE
);
-- Programmatic API keys (created by admins only). The raw key is shown once at
-- creation; only its PBKDF2 hash is stored. key_prefix is the first few
-- characters, kept in clear so keys are identifiable in the UI. Clients send
-- the raw key in the X-API-Key header.
CREATE TABLE IF NOT EXISTS radadmin_api_keys (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(64) NOT NULL,
key_prefix VARCHAR(16) NOT NULL,
key_hash VARCHAR(255) NOT NULL,
created_by VARCHAR(64) NULL,
created_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
last_used_at DATETIME NULL,
revoked TINYINT(1) NOT NULL DEFAULT 0,
PRIMARY KEY (id)
);
-- Activity log: one row per state-changing action or auth event, with the
-- acting username, what they did, and where from. Admin-only in the UI.
CREATE TABLE IF NOT EXISTS radadmin_logs (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
username VARCHAR(64) NULL,
action VARCHAR(64) NOT NULL,
detail VARCHAR(512) NULL,
ip_address VARCHAR(45) NULL,
created_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_radadmin_logs_username (username),
KEY idx_radadmin_logs_created (created_at)
);