thermograph/backend/notifications/discord_link.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

385 lines
16 KiB
Python

"""Discord as an identity: sign in with it, and connect it to an account.
Two flows over one authorization-code grant, told apart by the signed `state`:
link — /discord/link/start an already-signed-in user attaches a Discord
account to the session they're in
login — /discord/login/start an anonymous visitor authenticates, creating or
resolving a Thermograph account from the Discord
identity
**Both come back to /discord/link/callback.** Discord only honours redirect URIs
pre-registered in the developer portal, so a second callback path would mean an
operator has to add one there before login could work anywhere. Sharing the
registered one keeps this deployable with no portal change; the flows separate on
the state's purpose, which is inside the HMAC and so can't be flipped by hand.
`state` is signed with the app's auth secret (stdlib hmac, no new dependency) and
carries the purpose plus, for a link, the Thermograph user id — so a callback can't
be replayed, bound to a different account, or replayed *as the other flow*.
How a login resolves to an account, in order:
1. A user already carrying this `discord_id` — the durable key, no email needed.
2. Otherwise the Discord email, but **only if Discord reports it verified**.
That flag is the sole evidence the person owns the address; matching an
existing Thermograph account on an unverified one would hand that account to
whoever typed the address into Discord. Unverified is refused outright.
3. No account for that email: create one. It has no password its owner has ever
seen, so it is flagged `discord_only` and unlink is refused (see `unlink`) —
otherwise unlinking would delete the only way back in.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import os
import time
import urllib.parse
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from accounts.db import get_async_session
from accounts.models import User
from accounts.users import (
SECRET,
attach_session,
current_active_user,
current_user_optional,
get_access_token_db,
get_user_manager,
)
from core import audit
router = APIRouter(tags=["discord"])
CLIENT_ID = os.environ.get("THERMOGRAPH_DISCORD_APP_ID", "").strip()
CLIENT_SECRET = os.environ.get("THERMOGRAPH_DISCORD_CLIENT_SECRET", "").strip()
BASE = os.environ.get("THERMOGRAPH_BASE", "/thermograph").rstrip("/")
_AUTHORIZE = "https://discord.com/api/oauth2/authorize"
_TOKEN = "https://discord.com/api/oauth2/token"
_ME = "https://discord.com/api/users/@me"
# Linking only needs the account id. Signing in also needs the address to match or
# create a Thermograph account by, which is a separate scope Discord prompts for.
_SCOPE_LINK = "identify"
_SCOPE_LOGIN = "identify email"
# How long a start->callback round trip may take before the signed state expires.
_STATE_TTL = 600
_TIMEOUT = httpx.Timeout(10.0)
def enabled() -> bool:
return bool(CLIENT_ID and CLIENT_SECRET)
# --- signed state ------------------------------------------------------------
def _b64(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
def _unb64(s: str) -> bytes:
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
def _sign_state(uid: str | None, purpose: str = "link") -> str:
payload = _b64(json.dumps(
{"u": uid or "", "t": int(time.time()), "p": purpose}
).encode())
mac = hmac.new(SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest()[:32]
return f"{payload}.{mac}"
def _verify_state(state: str, purpose: str = "link") -> str | None:
"""The Thermograph user id this state was minted for, or None if it's missing,
tampered, expired, or was minted for a *different* purpose.
A login state has no user id yet, so it verifies as ``""`` — falsy, but
distinct from the None that means "reject". Callers must test against None,
not truthiness, or a valid login state reads as a failure.
"""
try:
payload, mac = state.split(".", 1)
except (ValueError, AttributeError):
return None
expect = hmac.new(SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest()[:32]
if not hmac.compare_digest(mac, expect):
return None
try:
data = json.loads(_unb64(payload))
except (ValueError, json.JSONDecodeError):
return None
if time.time() - float(data.get("t", 0)) > _STATE_TTL:
return None
# States minted before the purpose field existed are links; they age out in
# _STATE_TTL, so this fallback only spans a deploy.
if data.get("p", "link") != purpose:
return None
return data.get("u") or ""
def _redirect_uri(request: Request) -> str:
"""The callback URL, matched to how the app is actually reached (scheme/host +
base path). Must equal the redirect registered in the Discord portal."""
proto = request.headers.get("x-forwarded-proto") or request.url.scheme
host = request.headers.get("host") or request.url.netloc
return f"{proto}://{host}{BASE}/api/v2/discord/link/callback"
def _authorize_redirect(request: Request, scope: str, state: str) -> RedirectResponse:
params = {
"client_id": CLIENT_ID,
"redirect_uri": _redirect_uri(request),
"response_type": "code",
"scope": scope,
"state": state,
"prompt": "consent",
}
return RedirectResponse(url=f"{_AUTHORIZE}?{urllib.parse.urlencode(params)}",
status_code=303)
def _account_redirect(status: str) -> RedirectResponse:
# Back to the alerts page, which surfaces the outcome as a toast; 303 so the
# browser issues a GET after the callback. The frontend serves this page at
# /alerts — the route is named for the feature, not for subscriptions.html.
return RedirectResponse(url=f"{BASE}/alerts?discord={status}", status_code=303)
# --- Discord API --------------------------------------------------------------
async def _fetch_profile(request: Request, code: str) -> dict | None:
"""Trade the authorization code for the Discord user object, or None if any
step fails. Callers treat None as a graceful error, never a crash."""
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
tok = await client.post(_TOKEN, data={
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
"grant_type": "authorization_code",
"code": code,
"redirect_uri": _redirect_uri(request),
}, headers={"Content-Type": "application/x-www-form-urlencoded"})
if tok.status_code >= 300:
return None
access = tok.json().get("access_token")
me = await client.get(_ME, headers={"Authorization": f"Bearer {access}"})
if me.status_code >= 300:
return None
profile = me.json()
except Exception: # noqa: BLE001 - any network/parse failure is a graceful error
return None
return profile if isinstance(profile, dict) else None
async def _user_by_discord_id(session: AsyncSession, discord_id: str) -> User | None:
return (
await session.execute(select(User).where(User.discord_id == discord_id))
).scalar_one_or_none()
async def _create_from_discord(user_manager, email: str, discord_id: str) -> User:
"""Create an account for a Discord identity that matched none of ours.
Deliberately goes around ``UserManager.create()``: there is no password to
validate, and its ``on_after_register`` mails a confirmation link for an
address Discord has already verified. The generated password exists only
because ``hashed_password`` is NOT NULL — nobody ever sees it, which is what
``discord_only`` records.
"""
password = user_manager.password_helper.generate()
user = await user_manager.user_db.create({
"email": email,
"hashed_password": user_manager.password_helper.hash(password),
"is_active": True,
"is_superuser": False,
"is_verified": True,
"discord_id": discord_id,
"discord_dm": True,
"discord_only": True,
})
audit.log_activity("auth.register", {"user_id": str(user.id), "via": "discord"})
return user
# --- routes ------------------------------------------------------------------
@router.get("/discord/config")
async def discord_config():
"""Whether Discord is configured on this server. The account menu and the sign-in
modal use it to offer Discord only when it will actually work — so an
unconfigured server surfaces no dead-end Discord UI, and enabling it later
(setting the OAuth env vars) makes both entries appear on their own."""
return {"enabled": enabled()}
@router.get("/discord/link/start")
async def link_start(request: Request, user=Depends(current_active_user)):
if not enabled():
return _account_redirect("unavailable")
return _authorize_redirect(request, _SCOPE_LINK, _sign_state(str(user.id), "link"))
@router.get("/discord/login/start")
async def login_start(request: Request, user=Depends(current_user_optional)):
"""Sign in with Discord. Unauthenticated by design — this is how you get a
session, not something you do with one."""
if not enabled():
return _account_redirect("unavailable")
# Someone already signed in who lands here wants to connect, not to be
# switched into whichever account the Discord identity resolves to.
if user is not None:
return _authorize_redirect(request, _SCOPE_LINK, _sign_state(str(user.id), "link"))
return _authorize_redirect(request, _SCOPE_LOGIN, _sign_state(None, "login"))
@router.get("/discord/link/callback")
async def link_callback(
request: Request,
user=Depends(current_user_optional),
session: AsyncSession = Depends(get_async_session),
user_manager=Depends(get_user_manager),
access_token_db=Depends(get_access_token_db),
):
"""The single redirect target both flows return to (see the module docstring)."""
if not enabled():
return _account_redirect("unavailable")
# User declined on Discord's screen, or the state doesn't check out.
code = request.query_params.get("code")
state = request.query_params.get("state", "")
if not code:
return _account_redirect("cancelled")
# Which flow minted this state? The purpose is inside the HMAC, so a state
# can only verify under the one it was signed for.
uid = _verify_state(state, "link")
if uid is not None:
return await _finish_link(request, code, uid, user, session)
if _verify_state(state, "login") is not None:
return await _finish_login(request, code, session, user_manager, access_token_db)
return _account_redirect("error")
async def _finish_link(request: Request, code: str, uid: str, user,
session: AsyncSession) -> RedirectResponse:
# Linking always acts on the person actually logged in, and only on the one
# the state was minted for.
if user is None or str(user.id) != uid:
return _account_redirect("error")
profile = await _fetch_profile(request, code)
if profile is None:
return _account_redirect("error")
discord_id = str(profile.get("id") or "")
if not discord_id:
return _account_redirect("error")
# discord_id is UNIQUE, so without this check the UPDATE would raise an
# IntegrityError and 500 instead of explaining itself.
owner = await _user_by_discord_id(session, discord_id)
if owner is not None and owner.id != user.id:
return _account_redirect("taken")
# Linking is an active opt-in, so turn DM alerts on; the user can mute them
# (POST /discord/dm) while staying linked.
await session.execute(
update(User).where(User.id == user.id).values(discord_id=discord_id, discord_dm=True)
)
await session.commit()
return _account_redirect("linked")
async def _finish_login(request: Request, code: str, session: AsyncSession,
user_manager, access_token_db) -> RedirectResponse:
"""Resolve the Discord identity to an account and sign in as it.
Deliberately ignores any session already present. A login state carries no
user id — it can't, there is no user yet — so it is replayable against whoever
happens to be signed in. Treating it as a link would therefore be a forced-
linking takeover: an attacker who gets a victim to open this callback with a
code from the *attacker's* Discord would attach their identity to the victim's
account, then sign in as them. Only /discord/link/start, whose state is bound
to a specific user id, may link. The worst a replayed login state can do is
sign someone into the attacker's own account.
"""
profile = await _fetch_profile(request, code)
if profile is None:
return _account_redirect("error")
discord_id = str(profile.get("id") or "")
if not discord_id:
return _account_redirect("error")
created = False
user = await _user_by_discord_id(session, discord_id)
if user is None:
# No account carries this Discord id, so fall back to the email — the only
# other identity Discord gives us, and only when Discord vouches for it.
email = str(profile.get("email") or "").strip().lower()
if not email:
return _account_redirect("noemail")
if profile.get("verified") is not True:
return _account_redirect("unverified")
user = await user_manager.user_db.get_by_email(email)
if user is None:
user = await _create_from_discord(user_manager, email, discord_id)
created = True
elif user.discord_id:
# That address belongs to an account already tied to a different
# Discord identity; connecting would silently move it.
return _account_redirect("mismatch")
elif not user.is_active:
# Checked before the link, not just before the session: a deactivated
# account must not quietly acquire a Discord identity on the way out.
return _account_redirect("inactive")
else:
await session.execute(
update(User).where(User.id == user.id)
.values(discord_id=discord_id, discord_dm=True)
)
await session.commit()
if not user.is_active:
return _account_redirect("inactive")
response = _account_redirect("created" if created else "signedin")
return await attach_session(response, user, user_manager, access_token_db, request)
@router.post("/discord/unlink", status_code=204)
async def unlink(
user=Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
):
if user.discord_only:
# The account has no password anyone has ever seen, and no reset-password
# route is mounted to set one — unlinking here is unrecoverable.
raise HTTPException(
status_code=409,
detail="This account was created with Discord and has no password, "
"so unlinking would leave no way to sign in.",
)
await session.execute(
update(User).where(User.id == user.id).values(discord_id=None, discord_dm=False)
)
await session.commit()
class DmToggle(BaseModel):
enabled: bool
@router.post("/discord/dm", status_code=204)
async def set_dm(
payload: DmToggle,
user=Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
):
"""Turn Discord DM alerts on or off without unlinking. A no-op unless a Discord
account is actually linked."""
await session.execute(
update(User).where(User.id == user.id, User.discord_id.is_not(None))
.values(discord_dm=payload.enabled)
)
await session.commit()