Files
Spoolman/spoolman/database/auth_user.py
T
Donkie 95f3acea6b auth: data layer and permission gates
Adds the user and session data layer, the dependency gates that enforce
permission levels, and the coverage check that proves no route was left
ungated. Nothing is wired to a route yet -- that is the next commit.

database/auth_user.py, database/auth_session.py
  Follow the vendor.py template, with one deliberate departure: no websocket
  notifier. websocket_manager.send fans out to every subscriber, so
  broadcasting account or session changes would hand every connected client
  the details of every account. Both module docstrings say so, because the
  omission otherwise looks like an oversight worth "fixing".

  create() and set_password() take an already-derived hash rather than a
  plaintext password. Deriving one blocks for ~50 ms, and these are called
  from async handlers where that would stall the single process; callers
  pick the sync or async hashing entry point according to where they run.

  Sessions slide their expiry forward on use, but only once last_seen is
  older than a minute. Without that throttle every authenticated request
  becomes a database write, which on SQLite serialises the whole application
  behind session bookkeeping.

auth/dependencies.py
  require_level() short-circuits to UNRESTRICTED before any database access
  when auth is off, which is what keeps the disabled path identical to
  before. It does not declare a get_db_session dependency: route-level
  dependencies are solved ahead of handler parameters, so doing so would
  open a session on every request including /health and /info, which need no
  database today and would start failing when one is unavailable. It opens
  its own short-lived session only when a credential is actually present.

  CSRF failure is 403, not 401. The credential is valid, so reporting
  "unauthenticated" would sign the user out over what is usually a stale tab.

  ws_authenticated() accepts the handshake before closing on rejection,
  which looks wrong and is load-bearing. uvicorn turns a close sent before
  the accept into an HTTP 403 and discards the code, and Starlette routes
  WebSocketException from a dependency through that same path, so neither
  can deliver 4401/4403. Accepting first is the only way the browser sees
  the real code, which is what lets the client tell "signed out, stop
  retrying" from "network blipped, retry". The existing close(4040) in
  api/v1/setting.py has never reached a client for this reason.

auth/coverage.py
  Gating routes individually is fail-open. This inverts it: walk the route
  table at import and refuse to start if anything lacks a gate. It descends
  through FastAPI's include_router wrappers, which are not flattened into
  the parent route list -- iterating app.routes alone sees 4 of the 49
  routes and would call the rest covered.

Also registers the auth_anonymous_read setting, with a comment forbidding
secrets in the settings table: settings are readable via GET /setting/ and
every write is broadcast to all websocket subscribers.

Verified: coverage check catches forgotten HTTP and websocket routes,
recognises gates declared either as dependencies= or as a handler
parameter, and honours the allow-list. Websocket matrix end to end -- auth
off passes through, no credential gives 4401, a forged cookie gives 4401, a
read-level session connects to a read route and gets 4403 on a manage
route, and path parameters still inject through functools.wraps. The
anonymous-read lookup fails closed on any error.
2026-07-28 09:01:34 +02:00

285 lines
8.1 KiB
Python

"""Helper functions for interacting with user database objects.
Unlike the vendor, filament and spool modules, nothing here emits a websocket event.
``websocket_manager.send`` fans out to every subscriber, so broadcasting account changes
would hand every connected client the details of every other account. Account state is
read back over the authenticated REST endpoints instead. Do not add a notifier here.
"""
import logging
from datetime import datetime, timedelta
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from spoolman.auth.levels import Level
from spoolman.database import models
from spoolman.exceptions import ItemNotFoundError
logger = logging.getLogger(__name__)
# Failed logins allowed before an account starts locking.
LOCKOUT_THRESHOLD = 5
# The first lockout, doubling for each subsequent failure.
LOCKOUT_BASE = timedelta(minutes=1)
# The longest an account is ever locked for. A lockout that grows without bound turns a
# password guess into a denial of service against the real owner.
LOCKOUT_MAX = timedelta(minutes=15)
def normalize_username(username: str) -> str:
"""Reduce a username to its stored form.
Args:
username: The username as typed.
Returns:
str: The trimmed, lowercased username.
"""
return username.strip().lower()
async def create(
*,
db: AsyncSession,
username: str,
password_hash: str | None,
level: Level,
is_admin: bool = False,
is_owner: bool = False,
display_name: str | None = None,
must_change_password: bool = False,
) -> models.AuthUser:
"""Add a new user to the database.
Takes an already-derived hash rather than a plaintext password. Deriving one blocks
for tens of milliseconds, and this module is called from async request handlers
where that would stall the whole process; callers pick
:func:`spoolman.auth.hashing.hash_password_async` or its synchronous twin according
to where they run.
Args:
db: The database session.
username: The username, stored lowercased.
password_hash: The encoded password hash, or None for an account that cannot
sign in with a password.
level: The permission level to grant.
is_admin: Whether the user administers other users.
is_owner: Whether the user owns the instance.
display_name: An optional friendly name.
must_change_password: Whether to force a password change at next sign-in.
Returns:
models.AuthUser: The created user.
"""
user = models.AuthUser(
username=normalize_username(username),
display_name=display_name,
email=None,
password_hash=password_hash,
level=str(level),
is_admin=is_admin,
is_owner=is_owner,
is_active=True,
must_change_password=must_change_password,
totp_secret=None,
totp_enabled=False,
oidc_issuer=None,
oidc_subject=None,
failed_logins=0,
locked_until=None,
registered=datetime.utcnow().replace(microsecond=0),
last_login=None,
)
db.add(user)
await db.commit()
return user
async def get_by_id(db: AsyncSession, user_id: int) -> models.AuthUser:
"""Get a user from the database by the unique ID.
Args:
db: The database session.
user_id: The user ID.
Raises:
ItemNotFoundError: If no such user exists.
Returns:
models.AuthUser: The user.
"""
user = await db.get(models.AuthUser, user_id)
if user is None:
raise ItemNotFoundError(f"No user with ID {user_id} found.")
return user
async def get_by_username(db: AsyncSession, username: str) -> models.AuthUser | None:
"""Get a user from the database by username.
Returns None rather than raising, because the caller is usually the login path and a
missing user there is an expected outcome, not an error.
Args:
db: The database session.
username: The username, in any case.
Returns:
Optional[models.AuthUser]: The user, or None.
"""
stmt = select(models.AuthUser).where(models.AuthUser.username == normalize_username(username))
return (await db.execute(stmt)).scalar_one_or_none()
async def count(db: AsyncSession) -> int:
"""Count the user accounts that exist.
Args:
db: The database session.
Returns:
int: The number of accounts.
"""
stmt = select(func.count()).select_from(models.AuthUser)
return (await db.execute(stmt)).scalar_one()
async def find(
*,
db: AsyncSession,
limit: int | None = None,
offset: int = 0,
) -> tuple[list[models.AuthUser], int]:
"""List user accounts.
Args:
db: The database session.
limit: The maximum number to return.
offset: How many to skip.
Returns:
tuple: The users, and the total number that exist.
"""
stmt = select(models.AuthUser).order_by(models.AuthUser.username)
total = await count(db)
if offset:
stmt = stmt.offset(offset)
if limit is not None:
stmt = stmt.limit(limit)
rows = list((await db.execute(stmt)).scalars().all())
return rows, total
async def set_password(
*,
db: AsyncSession,
user: models.AuthUser,
password_hash: str,
must_change: bool = False,
) -> None:
"""Replace a user's password.
Args:
db: The database session.
user: The user to modify.
password_hash: The encoded hash of the new password.
must_change: Whether to force another change at next sign-in.
"""
user.password_hash = password_hash
user.must_change_password = must_change
# A password change resets the lockout: the credential being guessed no longer
# exists, so continuing to hold the account shut serves no purpose.
user.failed_logins = 0
user.locked_until = None
await db.commit()
async def rehash_password(*, db: AsyncSession, user: models.AuthUser, password_hash: str) -> None:
"""Store a re-derived hash of the same password.
Used by the login path when the cost parameters have been raised since the stored
hash was produced. Deliberately leaves must_change_password, failed_logins and
locked_until alone: nothing about the user's credential has changed.
Args:
db: The database session.
user: The user to modify.
password_hash: The newly encoded hash.
"""
user.password_hash = password_hash
await db.commit()
def lockout_for(failed_logins: int) -> timedelta | None:
"""Work out how long an account should be locked after a number of failures.
Args:
failed_logins: The running count of consecutive failures.
Returns:
Optional[timedelta]: How long to lock for, or None if below the threshold.
"""
if failed_logins < LOCKOUT_THRESHOLD:
return None
doublings = failed_logins - LOCKOUT_THRESHOLD
return min(LOCKOUT_BASE * (2**doublings), LOCKOUT_MAX)
async def record_login_failure(*, db: AsyncSession, user: models.AuthUser) -> None:
"""Note a failed sign-in, locking the account once failures pile up.
Persisted rather than kept only in the in-memory window so that a lockout survives a
restart.
Args:
db: The database session.
user: The user who failed to sign in.
"""
user.failed_logins += 1
lockout = lockout_for(user.failed_logins)
if lockout is not None:
user.locked_until = datetime.utcnow().replace(microsecond=0) + lockout
await db.commit()
async def record_login_success(*, db: AsyncSession, user: models.AuthUser) -> None:
"""Note a successful sign-in and clear any lockout.
Args:
db: The database session.
user: The user who signed in.
"""
user.failed_logins = 0
user.locked_until = None
user.last_login = datetime.utcnow().replace(microsecond=0)
await db.commit()
def is_locked(user: models.AuthUser) -> bool:
"""Check whether an account is currently locked out.
Args:
user: The user to check.
Returns:
bool: True if sign-in should be refused.
"""
if user.locked_until is None:
return False
return user.locked_until > datetime.utcnow()