mirror of
https://github.com/Donkie/Spoolman.git
synced 2026-10-21 17:42:16 -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.
195 lines
5.6 KiB
Python
195 lines
5.6 KiB
Python
"""Helper functions for interacting with login session database objects.
|
|
|
|
As with :mod:`spoolman.database.auth_user`, nothing here emits a websocket event -- the
|
|
subscriber pool is every connected client, and session activity is nobody else's
|
|
business.
|
|
"""
|
|
|
|
import logging
|
|
from datetime import datetime, timedelta
|
|
|
|
from sqlalchemy import delete, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from spoolman.auth.cookies import IDLE_LIFETIME, REMEMBER_LIFETIME
|
|
from spoolman.auth.hashing import hash_token, new_token
|
|
from spoolman.database import models
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# How stale last_seen must be before a request bothers to slide the session forward.
|
|
#
|
|
# Without this, every authenticated request becomes a database write. On SQLite -- the
|
|
# default, a single file behind one write lock -- that serialises the whole application
|
|
# behind session bookkeeping and would undo the list-page latency work that the
|
|
# selectin loading in models.py exists for. A minute of imprecision against a twelve
|
|
# hour idle window is not worth a write per request.
|
|
RENEW_THRESHOLD = timedelta(minutes=1)
|
|
|
|
# Cap on how much of a user agent string is retained for the session list.
|
|
USER_AGENT_MAX = 256
|
|
|
|
|
|
def lifetime_for(*, remember: bool) -> timedelta:
|
|
"""Get how long a session should live from now.
|
|
|
|
Args:
|
|
remember: Whether the user asked to stay signed in.
|
|
|
|
Returns:
|
|
timedelta: The session lifetime.
|
|
|
|
"""
|
|
return REMEMBER_LIFETIME if remember else IDLE_LIFETIME
|
|
|
|
|
|
async def create(
|
|
*,
|
|
db: AsyncSession,
|
|
user_id: int,
|
|
remember: bool,
|
|
user_agent: str | None = None,
|
|
ip: str | None = None,
|
|
) -> tuple[models.AuthSession, str]:
|
|
"""Start a new session.
|
|
|
|
Args:
|
|
db: The database session.
|
|
user_id: Who the session belongs to.
|
|
remember: Whether the session should outlive the browser session.
|
|
user_agent: The client's user agent, for the session list.
|
|
ip: The client's address, for the session list.
|
|
|
|
Returns:
|
|
tuple: The stored session and the plaintext token, which is the only time the
|
|
token exists in a readable form.
|
|
|
|
"""
|
|
token = new_token()
|
|
now = datetime.utcnow().replace(microsecond=0)
|
|
session = models.AuthSession(
|
|
user_id=user_id,
|
|
token_hash=hash_token(token),
|
|
created=now,
|
|
expires=now + lifetime_for(remember=remember),
|
|
last_seen=now,
|
|
remember=remember,
|
|
user_agent=user_agent[:USER_AGENT_MAX] if user_agent else None,
|
|
ip=ip or None,
|
|
)
|
|
db.add(session)
|
|
await db.commit()
|
|
return session, token
|
|
|
|
|
|
async def resolve(db: AsyncSession, token: str) -> models.AuthSession | None:
|
|
"""Look up a live session by its token, sliding its expiry forward.
|
|
|
|
An expired session is deleted as it is encountered, which keeps the table tidy
|
|
without needing a scheduled sweep.
|
|
|
|
Args:
|
|
db: The database session.
|
|
token: The plaintext token from the cookie.
|
|
|
|
Returns:
|
|
Optional[models.AuthSession]: The session, or None if unknown or expired.
|
|
|
|
"""
|
|
if not token:
|
|
return None
|
|
stmt = select(models.AuthSession).where(models.AuthSession.token_hash == hash_token(token))
|
|
session = (await db.execute(stmt)).scalar_one_or_none()
|
|
if session is None:
|
|
return None
|
|
|
|
now = datetime.utcnow().replace(microsecond=0)
|
|
if session.expires <= now:
|
|
await db.delete(session)
|
|
await db.commit()
|
|
return None
|
|
|
|
if now - session.last_seen >= RENEW_THRESHOLD:
|
|
session.last_seen = now
|
|
session.expires = now + lifetime_for(remember=session.remember)
|
|
await db.commit()
|
|
|
|
return session
|
|
|
|
|
|
async def revoke(db: AsyncSession, session_id: int) -> None:
|
|
"""End one session.
|
|
|
|
Args:
|
|
db: The database session.
|
|
session_id: Which session to end.
|
|
|
|
"""
|
|
await db.execute(delete(models.AuthSession).where(models.AuthSession.id == session_id))
|
|
await db.commit()
|
|
|
|
|
|
async def revoke_by_token(db: AsyncSession, token: str) -> None:
|
|
"""End the session identified by a token.
|
|
|
|
Args:
|
|
db: The database session.
|
|
token: The plaintext token from the cookie.
|
|
|
|
"""
|
|
if not token:
|
|
return
|
|
await db.execute(delete(models.AuthSession).where(models.AuthSession.token_hash == hash_token(token)))
|
|
await db.commit()
|
|
|
|
|
|
async def revoke_all_for_user(db: AsyncSession, user_id: int) -> int:
|
|
"""End every session belonging to a user.
|
|
|
|
Args:
|
|
db: The database session.
|
|
user_id: Whose sessions to end.
|
|
|
|
Returns:
|
|
int: How many sessions were ended.
|
|
|
|
"""
|
|
result = await db.execute(delete(models.AuthSession).where(models.AuthSession.user_id == user_id))
|
|
await db.commit()
|
|
return result.rowcount or 0
|
|
|
|
|
|
async def find_for_user(*, db: AsyncSession, user_id: int) -> list[models.AuthSession]:
|
|
"""List a user's live sessions, most recently used first.
|
|
|
|
Args:
|
|
db: The database session.
|
|
user_id: Whose sessions to list.
|
|
|
|
Returns:
|
|
list[models.AuthSession]: The sessions.
|
|
|
|
"""
|
|
stmt = (
|
|
select(models.AuthSession)
|
|
.where(models.AuthSession.user_id == user_id)
|
|
.order_by(models.AuthSession.last_seen.desc())
|
|
)
|
|
return list((await db.execute(stmt)).scalars().all())
|
|
|
|
|
|
async def prune_expired(db: AsyncSession) -> int:
|
|
"""Delete every session that has expired.
|
|
|
|
Args:
|
|
db: The database session.
|
|
|
|
Returns:
|
|
int: How many sessions were removed.
|
|
|
|
"""
|
|
now = datetime.utcnow().replace(microsecond=0)
|
|
result = await db.execute(delete(models.AuthSession).where(models.AuthSession.expires <= now))
|
|
await db.commit()
|
|
return result.rowcount or 0
|