This commit is contained in:
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user