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
162 lines
7.6 KiB
Python
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
|