Files
shihaam 66073c7891
build-and-push / build (push) Failing after 35s
added multi user support, move api key to database
2026-08-01 13:16:54 +05:00

168 lines
5.9 KiB
Python

"""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 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 .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)
# ---------------------------------------------------------------------------
# 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()