5ef2af6a9f
Establish the LLeMbas foundation: FastAPI/Jinja/SQLite layout, the ORM schema, and the original SVG identity. Notable decisions, all recorded in comments at the point they matter: - No Alembic. SQLite only, schema created at startup, so models carry a few columns nothing reads yet (Message.parent_id for branching, content_parts_json for multimodal turns). Adding them later to a live database without migrations is the painful path. - Sessions are server-side rows keyed by a SHA-256 of the cookie value, not JWTs, so logout and bans revoke access immediately. - Upstream API keys are Fernet-encrypted with a key derived from LEMBAS_SECRET_KEY. decrypt() fails soft to "" so rotating the secret degrades to re-entering keys rather than crashing the admin UI. - Artwork is generated by scripts/build_artwork.py rather than hand-drawn per file: the mallorn leaf appears in the icon, favicon, lockup and banner, and one source is the only way those stay in sync. The wordmark is Source Serif 4 (OFL) converted to outlines, because a README banner cannot load a webfont and <text> would render in whatever serif the viewer happens to have. - Icons live in a template partial, not assets/, because same-document <use href="#id"> is universally supported and the cross-document form is not. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
100 lines
2.7 KiB
Python
100 lines
2.7 KiB
Python
"""Login session lifecycle.
|
|
|
|
The browser holds an opaque random token in an httpOnly cookie. The database
|
|
stores only its SHA-256, so a dump of the sessions table cannot be replayed as
|
|
a live login. Tokens are compared by hash lookup, and revoking is a DELETE.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import secrets
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session as DBSession
|
|
|
|
from lembas.config import settings
|
|
from lembas.db.models import Session as SessionRow
|
|
from lembas.db.models import User
|
|
|
|
COOKIE_NAME = "lembas_session"
|
|
TOKEN_BYTES = 32
|
|
|
|
|
|
def _hash_token(token: str) -> str:
|
|
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def create_session(
|
|
db: DBSession,
|
|
user: User,
|
|
*,
|
|
user_agent: str = "",
|
|
ip_address: str = "",
|
|
) -> str:
|
|
"""Open a session for a user and return the raw token for the cookie.
|
|
|
|
The raw token is returned exactly once and never persisted.
|
|
"""
|
|
token = secrets.token_urlsafe(TOKEN_BYTES)
|
|
row = SessionRow(
|
|
user_id=user.id,
|
|
token_hash=_hash_token(token),
|
|
expires_at=datetime.now(UTC) + timedelta(seconds=settings.session_ttl),
|
|
user_agent=user_agent[:500],
|
|
ip_address=ip_address[:45],
|
|
)
|
|
db.add(row)
|
|
user.last_login_at = datetime.now(UTC)
|
|
db.commit()
|
|
return token
|
|
|
|
|
|
def resolve_session(db: DBSession, token: str | None) -> User | None:
|
|
"""Return the signed-in user for a cookie value, or None.
|
|
|
|
Expired and orphaned sessions are cleaned up as they are encountered, which
|
|
keeps the table tidy without needing a scheduled job.
|
|
"""
|
|
if not token:
|
|
return None
|
|
|
|
row = db.scalar(select(SessionRow).where(SessionRow.token_hash == _hash_token(token)))
|
|
if row is None:
|
|
return None
|
|
|
|
# SQLite hands back naive datetimes even for timezone-aware columns.
|
|
expires_at = row.expires_at
|
|
if expires_at.tzinfo is None:
|
|
expires_at = expires_at.replace(tzinfo=UTC)
|
|
|
|
if expires_at < datetime.now(UTC):
|
|
db.delete(row)
|
|
db.commit()
|
|
return None
|
|
|
|
user = db.get(User, row.user_id)
|
|
if user is None or not user.active:
|
|
db.delete(row)
|
|
db.commit()
|
|
return None
|
|
|
|
return user
|
|
|
|
|
|
def revoke_session(db: DBSession, token: str | None) -> None:
|
|
if not token:
|
|
return
|
|
row = db.scalar(select(SessionRow).where(SessionRow.token_hash == _hash_token(token)))
|
|
if row is not None:
|
|
db.delete(row)
|
|
db.commit()
|
|
|
|
|
|
def revoke_all_for_user(db: DBSession, user: User) -> None:
|
|
"""Sign a user out everywhere. Used when deactivating or changing a password."""
|
|
for row in db.scalars(select(SessionRow).where(SessionRow.user_id == user.id)):
|
|
db.delete(row)
|
|
db.commit()
|