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
+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,
)