* Add account system foundation: email/password auth with cookie sessions Introduce the app's first authoritative, user-owned data in a separate data/accounts.sqlite (SQLAlchemy), kept apart from the disposable derived-cache DB. Wire fastapi-users for email/password signup, cookie-based login/logout, and a session-check endpoint, backed by a database session strategy so logins survive restarts and are revocable. - db.py: async (aiosqlite) + sync SQLAlchemy engines over accounts.sqlite, WAL + foreign keys, create_db_and_tables(). - models.py: User, AccessToken, Subscription, Notification tables. - users.py: pwdlib hashing, HttpOnly cookie transport (path-scoped, SameSite=Lax, Secure via env), DatabaseStrategy sessions, current-user dependencies. - schemas.py: user + subscription + notification Pydantic models. - app.py: mount auth/register/users routers on v2, create tables at startup. - Pin fastapi-users[sqlalchemy]/aiosqlite; ignore data/accounts.sqlite*. * Add account header entry and auth modal (frontend) account.js self-injects a header entry (following the units.js pattern) that shows a Sign in button when logged out and an account menu when logged in, plus an auth modal reusing the existing .mp-overlay/.mp-modal chrome for email/password sign-in and account creation. A shared apiFetch helper sends the same-origin cookie for authed calls; exported getUser/openAuth/onAuthChange back later phases. Imported by every page entry module. On narrow screens the entry collapses to an icon-only button so it doesn't crowd the title. Enforce an 8-character minimum password in the user manager. * Add subscription CRUD API and the alerts management page Backend api_accounts.py adds user-scoped, cookie-authenticated endpoints to create/list/update/delete subscriptions (and the notification reads used next): POST snaps lat/lon to a grid cell, resolves a label, and rejects a duplicate location+kind with 409; PATCH/DELETE are ownership-checked (404 on mismatch). Mounted on the v2 prefix. Frontend subscriptions.js + subscriptions.html serve the /alerts page: a sign-in gate when logged out, an add flow that reuses the shared map picker and an editor modal (kind, watched metrics, 95-99 percentile, two-sided), and a card list with inline threshold/active edits and remove. Reachable from the account menu. * Add background subscription evaluation engine notify.py runs a daemon thread that periodically evaluates every active subscription: it groups them by grid cell, reads history from the parquet cache only (never spends archive quota) plus the hourly recent/forecast bundle, and grades candidate days with the existing grading.grade_day. A watched metric that lands at or beyond the threshold percentile fires a 'high' alert; a two-sided subscription also fires 'low' for the symmetric cold/calm/dry tail (precip stays one-directional). Observed subscriptions look at the last few recorded days, forecast subscriptions at the coming week. Two guards keep it quiet: a UNIQUE(subscription, event_date, metric, direction, kind) constraint dedups repeat events, and a per-subscription weekly cap (last_notified_at) limits each alert to one notification per 7 days. The loop tolerates a bad cell or an upstream rate limit without aborting the pass. Started and stopped from the app lifespan; gated by THERMOGRAPH_ENABLE_NOTIFIER. * Add in-app notification center (header bell) Extend account.js with a notification bell beside the account menu: an unread badge, a dropdown listing recent notifications (title, body, relative time), a per-item mark-read on click, and a Mark all read action, all through the cookie-authed notifications API. Unread state refreshes on open and polls every two minutes while signed in; polling stops on sign-out. Styled to match the app, responsive down to mobile. * Harden accounts: expired-session cleanup, engine tests, ops docs - notify.py sweeps expired login sessions (access tokens past their lifetime) once per evaluation pass. - Add hermetic unit tests for the evaluation engine's trigger detection (high/low tails, precip one-directional, normal = no trigger) and notification wording. - Document accounts.sqlite (authoritative, back it up), the single-worker requirement for the in-process evaluator, and the new env vars in DEPLOY.md.
86 lines
3.6 KiB
Python
86 lines
3.6 KiB
Python
"""fastapi-users wiring: password hashing, cookie sessions, the auth dependency.
|
|
|
|
We use the library end-to-end (no hand-rolled crypto): pwdlib hashing under the
|
|
hood, an HttpOnly cookie transport, and a *database* session strategy (tokens live
|
|
in the access_token table) so logins survive a restart and can be revoked.
|
|
"""
|
|
import os
|
|
import secrets
|
|
import uuid
|
|
|
|
from fastapi import Depends
|
|
from fastapi_users import BaseUserManager, FastAPIUsers, InvalidPasswordException, UUIDIDMixin
|
|
from fastapi_users.authentication import AuthenticationBackend, CookieTransport
|
|
from fastapi_users.authentication.strategy.db import DatabaseStrategy
|
|
from fastapi_users_db_sqlalchemy import SQLAlchemyUserDatabase
|
|
from fastapi_users_db_sqlalchemy.access_token import SQLAlchemyAccessTokenDatabase
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from db import get_async_session
|
|
from models import AccessToken, User
|
|
|
|
BASE = "/" + os.environ.get("THERMOGRAPH_BASE", "/thermograph").strip("/")
|
|
# Only used to sign reset/verification tokens — features that stay dormant until
|
|
# email is wired up. A per-boot random secret is fine while they're unused.
|
|
SECRET = os.environ.get("THERMOGRAPH_AUTH_SECRET") or secrets.token_urlsafe(32)
|
|
SESSION_TTL_SECONDS = int(os.environ.get("THERMOGRAPH_SESSION_TTL_DAYS", "30")) * 86400
|
|
|
|
|
|
def _cookie_secure() -> bool:
|
|
# The app serves plain HTTP on the LAN by default; a Secure cookie would never
|
|
# be sent over HTTP, silently breaking auth. So default OFF and let the TLS/VPS
|
|
# deploy opt in with THERMOGRAPH_COOKIE_SECURE=1. Accepted tradeoff: LAN traffic
|
|
# is unencrypted on a trusted network.
|
|
v = os.environ.get("THERMOGRAPH_COOKIE_SECURE", "").strip().lower()
|
|
return v in ("1", "true", "yes", "always", "on")
|
|
|
|
|
|
async def get_user_db(session: AsyncSession = Depends(get_async_session)):
|
|
yield SQLAlchemyUserDatabase(session, User)
|
|
|
|
|
|
async def get_access_token_db(session: AsyncSession = Depends(get_async_session)):
|
|
yield SQLAlchemyAccessTokenDatabase(session, AccessToken)
|
|
|
|
|
|
class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
|
|
reset_password_token_secret = SECRET
|
|
verification_token_secret = SECRET
|
|
|
|
async def validate_password(self, password: str, user) -> None:
|
|
if len(password) < 8:
|
|
raise InvalidPasswordException(reason="Password must be at least 8 characters.")
|
|
if len(password) > 1024:
|
|
# Guard against a scrypt/argon DoS from a megabyte "password".
|
|
raise InvalidPasswordException(reason="Password is too long.")
|
|
|
|
|
|
async def get_user_manager(user_db=Depends(get_user_db)):
|
|
yield UserManager(user_db)
|
|
|
|
|
|
cookie_transport = CookieTransport(
|
|
cookie_name="tg_session",
|
|
cookie_max_age=SESSION_TTL_SECONDS,
|
|
cookie_path=BASE, # scope the cookie to /thermograph, not the whole domain
|
|
cookie_secure=_cookie_secure(),
|
|
cookie_httponly=True, # unreadable by JS -> XSS can't steal the session
|
|
cookie_samesite="lax", # blocks cross-site form posts (CSRF), allows top-level nav
|
|
)
|
|
|
|
|
|
def get_database_strategy(access_token_db=Depends(get_access_token_db)) -> DatabaseStrategy:
|
|
return DatabaseStrategy(access_token_db, lifetime_seconds=SESSION_TTL_SECONDS)
|
|
|
|
|
|
auth_backend = AuthenticationBackend(
|
|
name="cookie",
|
|
transport=cookie_transport,
|
|
get_strategy=get_database_strategy,
|
|
)
|
|
|
|
fastapi_users = FastAPIUsers[User, uuid.UUID](get_user_manager, [auth_backend])
|
|
|
|
# Dependencies handlers use to require / peek at the logged-in user.
|
|
current_active_user = fastapi_users.current_user(active=True)
|
|
current_user_optional = fastapi_users.current_user(active=True, optional=True)
|