mirror of
https://github.com/Donkie/Spoolman.git
synced 2026-10-22 18:12:18 -05:00
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.
285 lines
8.1 KiB
Python
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()
|