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
385 lines
16 KiB
Python
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()
|