Scaffold project, data model and artwork
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>
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user