thermograph/discord_link.py

191 lines
6.8 KiB
Python
Raw Normal View History

"""Link a Thermograph account to a Discord account via OAuth2 (identify scope).
The Discord user id we store here is the durable key a later feature (DM alerts)
uses to reach the person. The flow is the standard authorization-code grant:
/link/start -> redirect the logged-in user to Discord's consent screen
/link/callback -> exchange the code, read the Discord user id, store it
/unlink -> forget it
`state` is signed with the app's auth secret (stdlib hmac, no new dependency) and
carries the Thermograph user id, so the callback can't be replayed or bound to a
different account. Everything requires an active session, so linking always acts on
the person who is actually logged 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, Request
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
from sqlalchemy import update
from sqlalchemy.ext.asyncio import AsyncSession
from db import get_async_session
from models import User
from users import SECRET, current_active_user
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"
# 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) -> str:
payload = _b64(json.dumps({"u": uid, "t": int(time.time())}).encode())
mac = hmac.new(SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest()[:32]
return f"{payload}.{mac}"
def _verify_state(state: str) -> str | None:
"""The Thermograph user id the state was minted for, or None if it's missing,
tampered, or older than the TTL."""
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
return data.get("u")
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 _account_redirect(status: str) -> RedirectResponse:
# Back to the alerts page, which surfaces the link state; 303 so the browser
# issues a GET after the callback.
return RedirectResponse(url=f"{BASE}/subscriptions?discord={status}", status_code=303)
# --- routes ------------------------------------------------------------------
@router.get("/discord/link/start")
async def link_start(request: Request, user=Depends(current_active_user)):
if not enabled():
return _account_redirect("unavailable")
params = {
"client_id": CLIENT_ID,
"redirect_uri": _redirect_uri(request),
"response_type": "code",
"scope": "identify",
"state": _sign_state(str(user.id)),
"prompt": "consent",
}
return RedirectResponse(url=f"{_AUTHORIZE}?{urllib.parse.urlencode(params)}", status_code=303)
@router.get("/discord/link/callback")
async def link_callback(
request: Request,
user=Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
):
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")
if _verify_state(state) != str(user.id):
return _account_redirect("error")
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 _account_redirect("error")
access = tok.json().get("access_token")
me = await client.get(_ME, headers={"Authorization": f"Bearer {access}"})
if me.status_code >= 300:
return _account_redirect("error")
discord_id = str(me.json().get("id") or "")
except Exception: # noqa: BLE001 - any network/parse failure is a graceful error
return _account_redirect("error")
if not discord_id:
return _account_redirect("error")
# 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")
@router.post("/discord/unlink", status_code=204)
async def unlink(
user=Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
):
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()