thermograph/backend/accounts/users.py
emi 8572c4aa6d
All checks were successful
Sync infra to hosts / sync-beta (push) Has been skipped
Sync infra to hosts / sync-prod (push) Has been skipped
Sync infra to hosts / sync-dev (push) Successful in 11s
secrets-guard / encrypted (push) Successful in 6s
shell-lint / shellcheck (push) Successful in 7s
Build + push images (Forgejo registry) / build-push (backend) (push) Successful in 1m21s
Build + push images (Forgejo registry) / build-push (frontend) (push) Successful in 1m19s
Deploy / deploy (frontend) (push) Successful in 1m27s
Deploy / deploy (backend) (push) Successful in 1m49s
secrets-guard / encrypted (pull_request) Successful in 5s
PR build (required check) / changes (pull_request) Successful in 7s
shell-lint / shellcheck (pull_request) Successful in 6s
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / build-frontend (pull_request) Successful in 1m16s
PR build (required check) / build-backend (pull_request) Successful in 1m36s
PR build (required check) / gate (pull_request) Successful in 1s
accounts: sign in with Discord, sharing the existing link callback (#115)
2026-07-26 18:16:55 +00:00

162 lines
7.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, Request
from fastapi.concurrency import run_in_threadpool
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 accounts.db import get_async_session
from accounts.models import AccessToken, User
from core import audit
from notifications import mailer
BASE = "/" + os.environ.get("THERMOGRAPH_BASE", "/thermograph").strip("/")
# The public origin, for links in emailed tokens — same var + default indexnow.py
# and discord.py already use for exactly this purpose (a link that has to resolve
# from wherever the recipient opens it, so it can't be relative to a request).
BASE_URL = os.environ.get("THERMOGRAPH_BASE_URL", "https://thermograph.org").rstrip("/")
# Signs verification tokens. THERMOGRAPH_AUTH_SECRET must be pinned before any
# such token is mailed — a per-boot random fallback would invalidate every
# outstanding link (and every session) on restart.
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")
_COOKIE_SECURE = _cookie_secure()
# Default "lax" (today's exact hardcoded behavior): blocks cross-site form posts
# (CSRF), allows top-level nav, and needs no Secure. "none" is what genuine
# cross-origin fetch (frontend and backend on different origins -- repo-split
# Stage 5) requires -- browsers refuse to store a SameSite=None cookie without
# Secure at all, so that combination fails loud here instead of manifesting
# only as "login mysteriously doesn't persist" once deployed.
COOKIE_SAMESITE = os.environ.get("THERMOGRAPH_COOKIE_SAMESITE", "lax").strip().lower()
if COOKIE_SAMESITE == "none" and not _COOKIE_SECURE:
raise RuntimeError(
"THERMOGRAPH_COOKIE_SAMESITE=none requires THERMOGRAPH_COOKIE_SECURE=1 "
"(browsers drop SameSite=None cookies that aren't also Secure)."
)
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 on_after_register(self, user: User, request: "Request | None" = None) -> None:
audit.log_activity("auth.register", {"user_id": str(user.id)})
# Kick off the same token-generation path a manual "resend verification"
# request would use, so there is exactly one place that builds the email
# (on_after_request_verify below) rather than two copies drifting apart.
await self.request_verify(user, request)
async def on_after_login(self, user: User, request: "Request | None" = None,
response=None) -> None:
audit.log_activity("auth.login", {"user_id": str(user.id), "verified": user.is_verified})
async def on_after_verify(self, user: User, request: "Request | None" = None) -> None:
audit.log_activity("auth.verify", {"user_id": str(user.id)})
async def on_after_request_verify(
self, user: User, token: str, request: "Request | None" = None
) -> None:
# mailer.send() already no-ops on the console/disabled backends (dev,
# tests) — the enabled() check here just skips building the message.
if not mailer.enabled():
return
link = f"{BASE_URL}{BASE}/?verify_token={token}"
# mailer.send() opens a blocking SMTP connection — fires on every
# register/resend-verification request, so keep it off the event loop.
await run_in_threadpool(
mailer.send,
user.email,
"Confirm your Thermograph account",
"Welcome to Thermograph! Confirm your email address to finish setting up "
f"your account:\n\n{link}\n\n"
"If you didn't create this account, you can safely ignore this email.",
)
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=COOKIE_SAMESITE,
)
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)
async def attach_session(response, user, user_manager, access_token_db, request=None):
"""Log `user` in by copying a fresh session cookie onto `response`.
The library's routers own this for password login, but an OAuth callback has to
end in a *redirect* the browser follows, not the 204 the login router returns.
So mint the session the normal way and lift the Set-Cookie header off the
library's response rather than re-deriving the cookie's name/TTL/flags here —
duplicating those would let the two logins drift into issuing different cookies.
"""
strategy = get_database_strategy(access_token_db)
issued = await auth_backend.login(strategy, user)
for key, value in issued.raw_headers:
if key.lower() == b"set-cookie":
response.raw_headers.append((key, value))
# The routers call this hook; a hand-rolled login has to as well, or Discord
# sign-ins go missing from the audit log that every password login appears in.
await user_manager.on_after_login(user, request, response)
return response