promote: dev → main (sign in with Discord) #116
9 changed files with 682 additions and 73 deletions
|
|
@ -34,13 +34,19 @@ class User(SQLAlchemyBaseUserTableUUID, Base):
|
||||||
# is_superuser, is_verified. Optional extras:
|
# is_superuser, is_verified. Optional extras:
|
||||||
display_name: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
display_name: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||||
# Linked Discord account id (OAuth2 identify) — the key DM alerts reach the user
|
# Linked Discord account id (OAuth2 identify) — the key DM alerts reach the user
|
||||||
# by. NB: create_all only makes this on a fresh DB; an existing prod accounts.db
|
# by, and the identity "Sign in with Discord" resolves an account from. Unique,
|
||||||
# needs the manual migration in deploy/migrations/ (no Alembic in this project).
|
# so one Discord account can never own two Thermograph accounts.
|
||||||
discord_id: Mapped[str | None] = mapped_column(String(32), unique=True, nullable=True)
|
discord_id: Mapped[str | None] = mapped_column(String(32), unique=True, nullable=True)
|
||||||
# Whether to also deliver alerts as a Discord DM. Set True on linking (an active
|
# Whether to also deliver alerts as a Discord DM. Set True on linking (an active
|
||||||
# opt-in); the user can mute it while staying linked. Same migration caveat.
|
# opt-in); the user can mute it while staying linked. Same migration caveat.
|
||||||
discord_dm: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False,
|
discord_dm: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False,
|
||||||
server_default=false())
|
server_default=false())
|
||||||
|
# True when the account was created by "Sign in with Discord" and so has no
|
||||||
|
# password its owner has ever seen (hashed_password is NOT NULL, so the row
|
||||||
|
# carries a generated one nobody knows). Load-bearing: unlinking Discord from
|
||||||
|
# such an account would remove its only way in, so discord_link.py refuses.
|
||||||
|
discord_only: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False,
|
||||||
|
server_default=false())
|
||||||
|
|
||||||
|
|
||||||
class AccessToken(SQLAlchemyBaseAccessTokenTableUUID, Base):
|
class AccessToken(SQLAlchemyBaseAccessTokenTableUUID, Base):
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,9 @@ class UserRead(schemas.BaseUser[uuid.UUID]):
|
||||||
# flow (discord_link.py), not editable through the profile.
|
# flow (discord_link.py), not editable through the profile.
|
||||||
discord_id: str | None = None
|
discord_id: str | None = None
|
||||||
discord_dm: bool = False
|
discord_dm: bool = False
|
||||||
|
# True when Discord is the account's only credential, so the UI can explain
|
||||||
|
# why "Unlink Discord" is refused rather than just failing.
|
||||||
|
discord_only: bool = False
|
||||||
|
|
||||||
|
|
||||||
class UserCreate(schemas.BaseUserCreate):
|
class UserCreate(schemas.BaseUserCreate):
|
||||||
|
|
|
||||||
|
|
@ -140,3 +140,23 @@ fastapi_users = FastAPIUsers[User, uuid.UUID](get_user_manager, [auth_backend])
|
||||||
# Dependencies handlers use to require / peek at the logged-in user.
|
# Dependencies handlers use to require / peek at the logged-in user.
|
||||||
current_active_user = fastapi_users.current_user(active=True)
|
current_active_user = fastapi_users.current_user(active=True)
|
||||||
current_user_optional = fastapi_users.current_user(active=True, optional=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
|
||||||
|
|
|
||||||
50
backend/alembic/versions/0003_user_discord_only.py
Normal file
50
backend/alembic/versions/0003_user_discord_only.py
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
"""user.discord_only — mark accounts whose only credential is Discord
|
||||||
|
|
||||||
|
"Sign in with Discord" (notifications/discord_link.py) creates accounts for people
|
||||||
|
who never chose a password. ``hashed_password`` is NOT NULL, so those rows carry a
|
||||||
|
generated one nobody has ever seen; without a flag there is no way to tell such an
|
||||||
|
account apart from a password account that merely linked Discord later. That
|
||||||
|
distinction is load-bearing: unlinking Discord from a Discord-only account would
|
||||||
|
delete its only way in, and no reset-password router is mounted to recover it.
|
||||||
|
|
||||||
|
Conditional on purpose. Revision 0001 builds the schema with
|
||||||
|
``Base.metadata.create_all``, which reads the *current* models.py — so a database
|
||||||
|
created after this column was added to the ORM already has it, and an
|
||||||
|
unconditional ``add_column`` would fail there with a duplicate-column error. Only
|
||||||
|
a database stamped before this revision is missing it.
|
||||||
|
|
||||||
|
Revision ID: 0003_user_discord_only
|
||||||
|
Revises: 0002_climate_hypertables
|
||||||
|
Create Date: 2026-07-26
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0003_user_discord_only"
|
||||||
|
down_revision = "0002_climate_hypertables"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _has_column() -> bool:
|
||||||
|
cols = sa.inspect(op.get_bind()).get_columns("user")
|
||||||
|
return any(c["name"] == "discord_only" for c in cols)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if _has_column():
|
||||||
|
return
|
||||||
|
# server_default (not just a Python-side default) so the backfill of existing
|
||||||
|
# rows happens in the same statement: every account that predates Discord
|
||||||
|
# login has a password, so False is correct for all of them.
|
||||||
|
op.add_column(
|
||||||
|
"user",
|
||||||
|
sa.Column("discord_only", sa.Boolean(), nullable=False,
|
||||||
|
server_default=sa.false()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if not _has_column():
|
||||||
|
return
|
||||||
|
op.drop_column("user", "discord_only")
|
||||||
|
|
@ -1,16 +1,33 @@
|
||||||
"""Link a Thermograph account to a Discord account via OAuth2 (identify scope).
|
"""Discord as an identity: sign in with it, and connect it to an account.
|
||||||
|
|
||||||
The Discord user id we store here is the durable key a later feature (DM alerts)
|
Two flows over one authorization-code grant, told apart by the signed `state`:
|
||||||
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 — /discord/link/start an already-signed-in user attaches a Discord
|
||||||
/link/callback -> exchange the code, read the Discord user id, store it
|
account to the session they're in
|
||||||
/unlink -> forget it
|
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
|
`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
|
carries the purpose plus, for a link, the Thermograph user id — so a callback can't
|
||||||
different account. Everything requires an active session, so linking always acts on
|
be replayed, bound to a different account, or replayed *as the other flow*.
|
||||||
the person who is actually logged in.
|
|
||||||
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -23,15 +40,23 @@ import time
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import update
|
from sqlalchemy import select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from accounts.db import get_async_session
|
from accounts.db import get_async_session
|
||||||
from accounts.models import User
|
from accounts.models import User
|
||||||
from accounts.users import SECRET, current_active_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"])
|
router = APIRouter(tags=["discord"])
|
||||||
|
|
||||||
|
|
@ -43,6 +68,11 @@ _AUTHORIZE = "https://discord.com/api/oauth2/authorize"
|
||||||
_TOKEN = "https://discord.com/api/oauth2/token"
|
_TOKEN = "https://discord.com/api/oauth2/token"
|
||||||
_ME = "https://discord.com/api/users/@me"
|
_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.
|
# How long a start->callback round trip may take before the signed state expires.
|
||||||
_STATE_TTL = 600
|
_STATE_TTL = 600
|
||||||
_TIMEOUT = httpx.Timeout(10.0)
|
_TIMEOUT = httpx.Timeout(10.0)
|
||||||
|
|
@ -61,15 +91,22 @@ def _unb64(s: str) -> bytes:
|
||||||
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
|
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
|
||||||
|
|
||||||
|
|
||||||
def _sign_state(uid: str) -> str:
|
def _sign_state(uid: str | None, purpose: str = "link") -> str:
|
||||||
payload = _b64(json.dumps({"u": uid, "t": int(time.time())}).encode())
|
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]
|
mac = hmac.new(SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest()[:32]
|
||||||
return f"{payload}.{mac}"
|
return f"{payload}.{mac}"
|
||||||
|
|
||||||
|
|
||||||
def _verify_state(state: str) -> str | None:
|
def _verify_state(state: str, purpose: str = "link") -> str | None:
|
||||||
"""The Thermograph user id the state was minted for, or None if it's missing,
|
"""The Thermograph user id this state was minted for, or None if it's missing,
|
||||||
tampered, or older than the TTL."""
|
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:
|
try:
|
||||||
payload, mac = state.split(".", 1)
|
payload, mac = state.split(".", 1)
|
||||||
except (ValueError, AttributeError):
|
except (ValueError, AttributeError):
|
||||||
|
|
@ -83,7 +120,11 @@ def _verify_state(state: str) -> str | None:
|
||||||
return None
|
return None
|
||||||
if time.time() - float(data.get("t", 0)) > _STATE_TTL:
|
if time.time() - float(data.get("t", 0)) > _STATE_TTL:
|
||||||
return None
|
return None
|
||||||
return data.get("u")
|
# 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:
|
def _redirect_uri(request: Request) -> str:
|
||||||
|
|
@ -94,52 +135,30 @@ def _redirect_uri(request: Request) -> str:
|
||||||
return f"{proto}://{host}{BASE}/api/v2/discord/link/callback"
|
return f"{proto}://{host}{BASE}/api/v2/discord/link/callback"
|
||||||
|
|
||||||
|
|
||||||
def _account_redirect(status: str) -> RedirectResponse:
|
def _authorize_redirect(request: Request, scope: str, state: 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/config")
|
|
||||||
async def discord_config():
|
|
||||||
"""Whether Discord account-linking is configured on this server. The account
|
|
||||||
menu uses it to show the "Link Discord" entry only when linking will actually
|
|
||||||
work — so an unconfigured server surfaces no dead-end Discord UI, and enabling
|
|
||||||
it later (setting the OAuth env vars) makes the entry appear on its 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")
|
|
||||||
params = {
|
params = {
|
||||||
"client_id": CLIENT_ID,
|
"client_id": CLIENT_ID,
|
||||||
"redirect_uri": _redirect_uri(request),
|
"redirect_uri": _redirect_uri(request),
|
||||||
"response_type": "code",
|
"response_type": "code",
|
||||||
"scope": "identify",
|
"scope": scope,
|
||||||
"state": _sign_state(str(user.id)),
|
"state": state,
|
||||||
"prompt": "consent",
|
"prompt": "consent",
|
||||||
}
|
}
|
||||||
return RedirectResponse(url=f"{_AUTHORIZE}?{urllib.parse.urlencode(params)}", status_code=303)
|
return RedirectResponse(url=f"{_AUTHORIZE}?{urllib.parse.urlencode(params)}",
|
||||||
|
status_code=303)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/discord/link/callback")
|
def _account_redirect(status: str) -> RedirectResponse:
|
||||||
async def link_callback(
|
# Back to the alerts page, which surfaces the outcome as a toast; 303 so the
|
||||||
request: Request,
|
# browser issues a GET after the callback. The frontend serves this page at
|
||||||
user=Depends(current_active_user),
|
# /alerts — the route is named for the feature, not for subscriptions.html.
|
||||||
session: AsyncSession = Depends(get_async_session),
|
return RedirectResponse(url=f"{BASE}/alerts?discord={status}", status_code=303)
|
||||||
):
|
|
||||||
if not enabled():
|
|
||||||
return _account_redirect("unavailable")
|
# --- Discord API --------------------------------------------------------------
|
||||||
# User declined on Discord's screen, or the state doesn't check out.
|
async def _fetch_profile(request: Request, code: str) -> dict | None:
|
||||||
code = request.query_params.get("code")
|
"""Trade the authorization code for the Discord user object, or None if any
|
||||||
state = request.query_params.get("state", "")
|
step fails. Callers treat None as a graceful error, never a crash."""
|
||||||
if not code:
|
|
||||||
return _account_redirect("cancelled")
|
|
||||||
if _verify_state(state) != str(user.id):
|
|
||||||
return _account_redirect("error")
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||||
tok = await client.post(_TOKEN, data={
|
tok = await client.post(_TOKEN, data={
|
||||||
|
|
@ -150,16 +169,121 @@ async def link_callback(
|
||||||
"redirect_uri": _redirect_uri(request),
|
"redirect_uri": _redirect_uri(request),
|
||||||
}, headers={"Content-Type": "application/x-www-form-urlencoded"})
|
}, headers={"Content-Type": "application/x-www-form-urlencoded"})
|
||||||
if tok.status_code >= 300:
|
if tok.status_code >= 300:
|
||||||
return _account_redirect("error")
|
return None
|
||||||
access = tok.json().get("access_token")
|
access = tok.json().get("access_token")
|
||||||
me = await client.get(_ME, headers={"Authorization": f"Bearer {access}"})
|
me = await client.get(_ME, headers={"Authorization": f"Bearer {access}"})
|
||||||
if me.status_code >= 300:
|
if me.status_code >= 300:
|
||||||
return _account_redirect("error")
|
return None
|
||||||
discord_id = str(me.json().get("id") or "")
|
profile = me.json()
|
||||||
except Exception: # noqa: BLE001 - any network/parse failure is a graceful error
|
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")
|
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:
|
if not discord_id:
|
||||||
return _account_redirect("error")
|
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
|
# Linking is an active opt-in, so turn DM alerts on; the user can mute them
|
||||||
# (POST /discord/dm) while staying linked.
|
# (POST /discord/dm) while staying linked.
|
||||||
await session.execute(
|
await session.execute(
|
||||||
|
|
@ -169,11 +293,73 @@ async def link_callback(
|
||||||
return _account_redirect("linked")
|
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)
|
@router.post("/discord/unlink", status_code=204)
|
||||||
async def unlink(
|
async def unlink(
|
||||||
user=Depends(current_active_user),
|
user=Depends(current_active_user),
|
||||||
session: AsyncSession = Depends(get_async_session),
|
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(
|
await session.execute(
|
||||||
update(User).where(User.id == user.id).values(discord_id=None, discord_dm=False)
|
update(User).where(User.id == user.id).values(discord_id=None, discord_dm=False)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
251
backend/tests/notifications/test_discord_login.py
Normal file
251
backend/tests/notifications/test_discord_login.py
Normal file
|
|
@ -0,0 +1,251 @@
|
||||||
|
"""Sign in with Discord: account resolution, session issuance, and the guards that
|
||||||
|
keep the login flow from becoming an account-takeover path. Discord HTTP is mocked;
|
||||||
|
reuses the throwaway accounts DB from conftest."""
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from web import app as appmod
|
||||||
|
from accounts import db
|
||||||
|
from notifications import discord_link as dl
|
||||||
|
|
||||||
|
V2 = "/thermograph/api/v2"
|
||||||
|
PW = "supersecret123"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module", autouse=True)
|
||||||
|
def _tables():
|
||||||
|
db.Base.metadata.create_all(db.sync_engine)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _configured(monkeypatch):
|
||||||
|
monkeypatch.setattr(dl, "CLIENT_ID", "app123")
|
||||||
|
monkeypatch.setattr(dl, "CLIENT_SECRET", "secret456")
|
||||||
|
|
||||||
|
|
||||||
|
class _Resp:
|
||||||
|
def __init__(self, code, data): self.status_code = code; self._data = data
|
||||||
|
def json(self): return self._data
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_discord(monkeypatch, profile):
|
||||||
|
"""Stand in for httpx.AsyncClient: token exchange, then /users/@me -> profile."""
|
||||||
|
class _Client:
|
||||||
|
def __init__(self, *a, **k): pass
|
||||||
|
async def __aenter__(self): return self
|
||||||
|
async def __aexit__(self, *a): return False
|
||||||
|
async def post(self, url, **k): return _Resp(200, {"access_token": "tok"})
|
||||||
|
async def get(self, url, **k): return _Resp(200, profile)
|
||||||
|
monkeypatch.setattr(dl.httpx, "AsyncClient", _Client)
|
||||||
|
|
||||||
|
|
||||||
|
def _register(client, email):
|
||||||
|
r = client.post(f"{V2}/auth/register", json={"email": email, "password": PW})
|
||||||
|
assert r.status_code in (201, 400)
|
||||||
|
|
||||||
|
|
||||||
|
def _login(client, email):
|
||||||
|
_register(client, email)
|
||||||
|
assert client.post(f"{V2}/auth/login",
|
||||||
|
data={"username": email, "password": PW}).status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
def _callback(client, state):
|
||||||
|
return client.get(f"{V2}/discord/link/callback?code=abc&state={state}",
|
||||||
|
follow_redirects=False)
|
||||||
|
|
||||||
|
|
||||||
|
# --- start -------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_login_start_needs_no_session_and_asks_for_email():
|
||||||
|
c = TestClient(appmod.app)
|
||||||
|
r = c.get(f"{V2}/discord/login/start", follow_redirects=False)
|
||||||
|
assert r.status_code == 303
|
||||||
|
loc = r.headers["location"]
|
||||||
|
assert loc.startswith("https://discord.com/api/oauth2/authorize")
|
||||||
|
# The email scope is what makes account resolution possible at all.
|
||||||
|
assert "scope=identify+email" in loc and "state=" in loc
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_start_while_signed_in_links_instead_of_switching_account():
|
||||||
|
"""Someone already signed in wants to connect Discord, not be switched into
|
||||||
|
whichever account the Discord identity happens to resolve to."""
|
||||||
|
c = TestClient(appmod.app)
|
||||||
|
_login(c, "already-in@example.com")
|
||||||
|
r = c.get(f"{V2}/discord/login/start", follow_redirects=False)
|
||||||
|
assert r.status_code == 303
|
||||||
|
loc = r.headers["location"]
|
||||||
|
assert "scope=identify&" in loc or loc.endswith("scope=identify")
|
||||||
|
state = loc.split("state=")[1].split("&")[0]
|
||||||
|
uid = c.get(f"{V2}/users/me").json()["id"]
|
||||||
|
assert dl._verify_state(state, "link") == uid
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_start_when_unconfigured_bounces_back(monkeypatch):
|
||||||
|
monkeypatch.setattr(dl, "CLIENT_ID", "")
|
||||||
|
monkeypatch.setattr(dl, "CLIENT_SECRET", "")
|
||||||
|
c = TestClient(appmod.app)
|
||||||
|
r = c.get(f"{V2}/discord/login/start", follow_redirects=False)
|
||||||
|
assert r.status_code == 303 and "discord=unavailable" in r.headers["location"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- state purposes are not interchangeable ----------------------------------
|
||||||
|
|
||||||
|
def test_state_purposes_do_not_cross_over():
|
||||||
|
login_state = dl._sign_state(None, "login")
|
||||||
|
link_state = dl._sign_state("user-123", "link")
|
||||||
|
# A login state carries no user id, but verifies as "" — distinct from reject.
|
||||||
|
assert dl._verify_state(login_state, "login") == ""
|
||||||
|
assert dl._verify_state(login_state, "link") is None
|
||||||
|
assert dl._verify_state(link_state, "link") == "user-123"
|
||||||
|
assert dl._verify_state(link_state, "login") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_login_state_cannot_link_the_signed_in_account(monkeypatch):
|
||||||
|
"""Forced-linking guard. A login state carries no user id, so it is replayable
|
||||||
|
against whoever is signed in; if the callback treated it as a link, an attacker
|
||||||
|
could attach their own Discord identity to a victim's account and then sign in
|
||||||
|
as them. It must complete as a plain login instead."""
|
||||||
|
_mock_discord(monkeypatch, {"id": "d-crossover", "email": "attacker@example.com",
|
||||||
|
"verified": True})
|
||||||
|
c = TestClient(appmod.app)
|
||||||
|
_login(c, "crossover@example.com")
|
||||||
|
# A state whose purpose says login, but naming the signed-in user — the shape
|
||||||
|
# a forced-link attempt would take.
|
||||||
|
r = _callback(c, dl._sign_state(c.get(f"{V2}/users/me").json()["id"], "login"))
|
||||||
|
# Resolved as a login into the Discord identity's own account, not as a link.
|
||||||
|
assert "discord=created" in r.headers["location"]
|
||||||
|
assert c.get(f"{V2}/users/me").json()["email"] == "attacker@example.com"
|
||||||
|
# The account that had been signed in is untouched.
|
||||||
|
victim = TestClient(appmod.app)
|
||||||
|
_login(victim, "crossover@example.com")
|
||||||
|
assert victim.get(f"{V2}/users/me").json()["discord_id"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- login resolves an account -----------------------------------------------
|
||||||
|
|
||||||
|
def test_login_creates_an_account_and_signs_in(monkeypatch):
|
||||||
|
_mock_discord(monkeypatch, {"id": "d-new", "email": "New.User@Example.com",
|
||||||
|
"verified": True})
|
||||||
|
c = TestClient(appmod.app)
|
||||||
|
r = _callback(c, dl._sign_state(None, "login"))
|
||||||
|
assert r.status_code == 303 and "discord=created" in r.headers["location"]
|
||||||
|
me = c.get(f"{V2}/users/me")
|
||||||
|
assert me.status_code == 200
|
||||||
|
body = me.json()
|
||||||
|
assert body["email"] == "new.user@example.com" # normalised
|
||||||
|
assert body["discord_id"] == "d-new"
|
||||||
|
assert body["is_verified"] is True # Discord vouched for it
|
||||||
|
assert body["discord_only"] is True
|
||||||
|
assert body["discord_dm"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_recognises_a_previously_linked_account(monkeypatch):
|
||||||
|
"""The discord_id is the durable key: it resolves the account with no email."""
|
||||||
|
_mock_discord(monkeypatch, {"id": "d-returning", "email": "link-me@example.com",
|
||||||
|
"verified": True})
|
||||||
|
linker = TestClient(appmod.app)
|
||||||
|
_login(linker, "link-me@example.com")
|
||||||
|
uid = linker.get(f"{V2}/users/me").json()["id"]
|
||||||
|
assert "discord=linked" in _callback(linker, dl._sign_state(uid, "link")
|
||||||
|
).headers["location"]
|
||||||
|
# A fresh client, no session, and Discord now returns no email at all.
|
||||||
|
_mock_discord(monkeypatch, {"id": "d-returning"})
|
||||||
|
c = TestClient(appmod.app)
|
||||||
|
r = _callback(c, dl._sign_state(None, "login"))
|
||||||
|
assert r.status_code == 303 and "discord=signedin" in r.headers["location"]
|
||||||
|
assert c.get(f"{V2}/users/me").json()["id"] == uid
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_connects_a_verified_email_to_an_existing_account(monkeypatch):
|
||||||
|
"""This is the 'connect the account with that' path: an existing password
|
||||||
|
account picks up the Discord id and is signed in."""
|
||||||
|
c = TestClient(appmod.app)
|
||||||
|
_register(c, "existing@example.com")
|
||||||
|
_mock_discord(monkeypatch, {"id": "d-existing", "email": "existing@example.com",
|
||||||
|
"verified": True})
|
||||||
|
fresh = TestClient(appmod.app)
|
||||||
|
r = _callback(fresh, dl._sign_state(None, "login"))
|
||||||
|
assert r.status_code == 303 and "discord=signedin" in r.headers["location"]
|
||||||
|
body = fresh.get(f"{V2}/users/me").json()
|
||||||
|
assert body["email"] == "existing@example.com"
|
||||||
|
assert body["discord_id"] == "d-existing"
|
||||||
|
# It kept its password, so it is not Discord-only and may unlink.
|
||||||
|
assert body["discord_only"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_refuses_an_unverified_discord_email(monkeypatch):
|
||||||
|
"""Discord's verified flag is the only evidence the person owns the address.
|
||||||
|
Without it, this would hand over any account whose email someone typed in."""
|
||||||
|
c = TestClient(appmod.app)
|
||||||
|
_register(c, "victim@example.com")
|
||||||
|
_mock_discord(monkeypatch, {"id": "d-attacker", "email": "victim@example.com",
|
||||||
|
"verified": False})
|
||||||
|
fresh = TestClient(appmod.app)
|
||||||
|
r = _callback(fresh, dl._sign_state(None, "login"))
|
||||||
|
assert r.status_code == 303 and "discord=unverified" in r.headers["location"]
|
||||||
|
assert fresh.get(f"{V2}/users/me").status_code == 401 # no session issued
|
||||||
|
# And the targeted account is untouched.
|
||||||
|
_login(c, "victim@example.com")
|
||||||
|
assert c.get(f"{V2}/users/me").json()["discord_id"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_without_an_email_cannot_create_an_account(monkeypatch):
|
||||||
|
_mock_discord(monkeypatch, {"id": "d-noemail"})
|
||||||
|
c = TestClient(appmod.app)
|
||||||
|
r = _callback(c, dl._sign_state(None, "login"))
|
||||||
|
assert r.status_code == 303 and "discord=noemail" in r.headers["location"]
|
||||||
|
assert c.get(f"{V2}/users/me").status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_will_not_move_an_account_between_discord_identities(monkeypatch):
|
||||||
|
"""The email matches an account that is already linked to a different Discord
|
||||||
|
id — signing in would silently re-point it."""
|
||||||
|
owner = TestClient(appmod.app)
|
||||||
|
_login(owner, "owned@example.com")
|
||||||
|
uid = owner.get(f"{V2}/users/me").json()["id"]
|
||||||
|
_mock_discord(monkeypatch, {"id": "d-owner", "email": "owned@example.com",
|
||||||
|
"verified": True})
|
||||||
|
assert "discord=linked" in _callback(owner, dl._sign_state(uid, "link")
|
||||||
|
).headers["location"]
|
||||||
|
# A second Discord account claiming the same address.
|
||||||
|
_mock_discord(monkeypatch, {"id": "d-interloper", "email": "owned@example.com",
|
||||||
|
"verified": True})
|
||||||
|
fresh = TestClient(appmod.app)
|
||||||
|
r = _callback(fresh, dl._sign_state(None, "login"))
|
||||||
|
assert r.status_code == 303 and "discord=mismatch" in r.headers["location"]
|
||||||
|
assert fresh.get(f"{V2}/users/me").status_code == 401
|
||||||
|
assert owner.get(f"{V2}/users/me").json()["discord_id"] == "d-owner"
|
||||||
|
|
||||||
|
|
||||||
|
# --- guards on the connected account -----------------------------------------
|
||||||
|
|
||||||
|
def test_linking_a_discord_account_someone_else_owns_is_refused(monkeypatch):
|
||||||
|
first = TestClient(appmod.app)
|
||||||
|
_login(first, "first-owner@example.com")
|
||||||
|
_mock_discord(monkeypatch, {"id": "d-contested", "email": "first-owner@example.com",
|
||||||
|
"verified": True})
|
||||||
|
uid = first.get(f"{V2}/users/me").json()["id"]
|
||||||
|
assert "discord=linked" in _callback(first, dl._sign_state(uid, "link")
|
||||||
|
).headers["location"]
|
||||||
|
# A different Thermograph account tries to claim the same Discord identity.
|
||||||
|
second = TestClient(appmod.app)
|
||||||
|
_login(second, "second-owner@example.com")
|
||||||
|
uid2 = second.get(f"{V2}/users/me").json()["id"]
|
||||||
|
r = _callback(second, dl._sign_state(uid2, "link"))
|
||||||
|
assert r.status_code == 303 and "discord=taken" in r.headers["location"]
|
||||||
|
assert second.get(f"{V2}/users/me").json()["discord_id"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_discord_only_account_cannot_unlink_itself_into_a_lockout(monkeypatch):
|
||||||
|
_mock_discord(monkeypatch, {"id": "d-onlyway", "email": "onlyway@example.com",
|
||||||
|
"verified": True})
|
||||||
|
c = TestClient(appmod.app)
|
||||||
|
assert "discord=created" in _callback(c, dl._sign_state(None, "login")
|
||||||
|
).headers["location"]
|
||||||
|
r = c.post(f"{V2}/discord/unlink")
|
||||||
|
assert r.status_code == 409
|
||||||
|
assert "no password" in r.json()["detail"]
|
||||||
|
assert c.get(f"{V2}/users/me").json()["discord_id"] == "d-onlyway"
|
||||||
|
# Muting DMs is still fine — that is not a lockout.
|
||||||
|
assert c.post(f"{V2}/discord/dm", json={"enabled": False}).status_code == 204
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
// units.js is (imported by each page's entry module).
|
// units.js is (imported by each page's entry module).
|
||||||
|
|
||||||
let currentUser = null; // {id, email, display_name} or null
|
let currentUser = null; // {id, email, display_name} or null
|
||||||
let discordEnabled = false; // is Discord linking configured on the server?
|
let discordEnabled = false; // is Discord configured on the server?
|
||||||
let discordChecked = false; // have we asked yet? (once per page load)
|
let discordChecked = false; // have we asked yet? (once per page load)
|
||||||
const authCbs = []; // notified on login/logout so pages can re-gate
|
const authCbs = []; // notified on login/logout so pages can re-gate
|
||||||
|
|
||||||
|
|
@ -89,10 +89,10 @@ async function refreshUser() {
|
||||||
const res = await apiFetch(uv("users/me"));
|
const res = await apiFetch(uv("users/me"));
|
||||||
currentUser = res.ok ? await res.json() : null;
|
currentUser = res.ok ? await res.json() : null;
|
||||||
} catch (e) { currentUser = null; }
|
} catch (e) { currentUser = null; }
|
||||||
// Learn once whether Discord linking is configured, so the account menu only
|
// Learn once whether Discord is configured, so we only offer "Link Discord" and
|
||||||
// offers "Link Discord" when it will actually work (checked lazily, and only
|
// "Continue with Discord" when they'll actually work. Asked regardless of sign-in
|
||||||
// for a signed-in user — the menu never shows it to anyone else).
|
// state: the sign-in modal needs the answer precisely when nobody is signed in.
|
||||||
if (currentUser && !discordChecked) {
|
if (!discordChecked) {
|
||||||
discordChecked = true;
|
discordChecked = true;
|
||||||
try {
|
try {
|
||||||
const r = await apiFetch(uv("discord/config"));
|
const r = await apiFetch(uv("discord/config"));
|
||||||
|
|
@ -126,6 +126,10 @@ export async function logout() {
|
||||||
// --- auth modal --------------------------------------------------------------
|
// --- auth modal --------------------------------------------------------------
|
||||||
let modal = null, mode = "login";
|
let modal = null, mode = "login";
|
||||||
|
|
||||||
|
// Discord's brand mark ("Clyde"), inline like the other icons here so it needs no
|
||||||
|
// extra request and inherits currentColor.
|
||||||
|
const DISCORD_IC = `<svg viewBox="0 0 24 18" fill="currentColor" aria-hidden="true"><path d="M20.32 1.51A19.79 19.79 0 0 0 15.43 0a13.9 13.9 0 0 0-.63 1.28 18.4 18.4 0 0 0-5.6 0A13.9 13.9 0 0 0 8.57 0 19.74 19.74 0 0 0 3.68 1.51C.57 6.15-.28 10.68.14 15.14a19.9 19.9 0 0 0 6.05 3.05c.49-.66.92-1.37 1.3-2.11a12.9 12.9 0 0 1-2.05-.98c.17-.13.34-.26.5-.4a14.2 14.2 0 0 0 12.12 0c.16.14.33.27.5.4-.65.38-1.34.71-2.05.98.37.74.81 1.45 1.3 2.11a19.87 19.87 0 0 0 6.05-3.05c.49-5.17-.84-9.67-3.54-13.63ZM8.02 12.4c-1.18 0-2.15-1.08-2.15-2.41S6.82 7.58 8.02 7.58s2.17 1.09 2.15 2.41c0 1.33-.95 2.41-2.15 2.41Zm7.96 0c-1.18 0-2.15-1.08-2.15-2.41s.95-2.41 2.15-2.41 2.17 1.09 2.15 2.41c0 1.33-.95 2.41-2.15 2.41Z"/></svg>`;
|
||||||
|
|
||||||
function buildModal() {
|
function buildModal() {
|
||||||
modal = document.createElement("div");
|
modal = document.createElement("div");
|
||||||
modal.className = "mp-overlay acct-overlay";
|
modal.className = "mp-overlay acct-overlay";
|
||||||
|
|
@ -145,6 +149,12 @@ function buildModal() {
|
||||||
</label>
|
</label>
|
||||||
<p class="acct-error" role="alert" hidden></p>
|
<p class="acct-error" role="alert" hidden></p>
|
||||||
<button type="submit" class="acct-submit">Sign in</button>
|
<button type="submit" class="acct-submit">Sign in</button>
|
||||||
|
<div class="acct-alt" hidden>
|
||||||
|
<span class="acct-or">or</span>
|
||||||
|
<a class="acct-discord-login" href="${uv("discord/login/start")}">
|
||||||
|
${DISCORD_IC}<span>Continue with Discord</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
<p class="acct-switch"></p>
|
<p class="acct-switch"></p>
|
||||||
</form>
|
</form>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
@ -173,6 +183,9 @@ function setMode(m) {
|
||||||
modal.querySelector(".acct-switch").innerHTML = isLogin
|
modal.querySelector(".acct-switch").innerHTML = isLogin
|
||||||
? 'Need an account? <button type="button">Create one</button>'
|
? 'Need an account? <button type="button">Create one</button>'
|
||||||
: 'Already have an account? <button type="button">Sign in</button>';
|
: 'Already have an account? <button type="button">Sign in</button>';
|
||||||
|
// One label for both modes: Discord signs you in or creates the account,
|
||||||
|
// whichever applies, so making the user pick first would be a false choice.
|
||||||
|
modal.querySelector(".acct-alt").hidden = !discordEnabled;
|
||||||
showError("");
|
showError("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -371,7 +384,11 @@ function renderHeader() {
|
||||||
<a href="${APP_BASE}/alerts" class="acct-pop-link">My alerts</a>
|
<a href="${APP_BASE}/alerts" class="acct-pop-link">My alerts</a>
|
||||||
${currentUser.discord_id
|
${currentUser.discord_id
|
||||||
? `<button type="button" class="acct-pop-link acct-discord-dm">${currentUser.discord_dm ? "Discord alerts: on" : "Discord alerts: off"}</button>`
|
? `<button type="button" class="acct-pop-link acct-discord-dm">${currentUser.discord_dm ? "Discord alerts: on" : "Discord alerts: off"}</button>`
|
||||||
+ '<button type="button" class="acct-pop-link acct-discord-unlink">Unlink Discord</button>'
|
// A Discord-created account has no password, so Discord is its only
|
||||||
|
// way in and the server refuses to unlink it — don't offer the button.
|
||||||
|
+ (currentUser.discord_only
|
||||||
|
? '<span class="acct-pop-note muted">Signed in with Discord</span>'
|
||||||
|
: '<button type="button" class="acct-pop-link acct-discord-unlink">Unlink Discord</button>')
|
||||||
: (discordEnabled ? `<a href="${uv("discord/link/start")}" class="acct-pop-link">Link Discord</a>` : "")}
|
: (discordEnabled ? `<a href="${uv("discord/link/start")}" class="acct-pop-link">Link Discord</a>` : "")}
|
||||||
<button type="button" class="acct-pop-link acct-signout">Sign out</button>
|
<button type="button" class="acct-pop-link acct-signout">Sign out</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -403,7 +420,17 @@ function renderHeader() {
|
||||||
const unlinkBtn = el.querySelector(".acct-discord-unlink");
|
const unlinkBtn = el.querySelector(".acct-discord-unlink");
|
||||||
if (unlinkBtn) unlinkBtn.addEventListener("click", async () => {
|
if (unlinkBtn) unlinkBtn.addEventListener("click", async () => {
|
||||||
unlinkBtn.disabled = true;
|
unlinkBtn.disabled = true;
|
||||||
try { await apiFetch(uv("discord/unlink"), { method: "POST" }); } catch (e) {}
|
try {
|
||||||
|
const res = await apiFetch(uv("discord/unlink"), { method: "POST" });
|
||||||
|
// 409: the account was created with Discord and has no password, so
|
||||||
|
// unlinking would lock it out. Say why instead of appearing to do nothing.
|
||||||
|
if (res.status === 409) {
|
||||||
|
const body = await res.json().catch(() => null);
|
||||||
|
showToast((body && body.detail) || "Discord can't be unlinked from this account.", true);
|
||||||
|
unlinkBtn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
await refreshUser();
|
await refreshUser();
|
||||||
emitAuth(); // repaint the popover in its unlinked state
|
emitAuth(); // repaint the popover in its unlinked state
|
||||||
});
|
});
|
||||||
|
|
@ -452,6 +479,30 @@ function showToast(msg, isError = false) {
|
||||||
toastTimer = setTimeout(() => { el.hidden = true; }, 6000);
|
toastTimer = setTimeout(() => { el.hidden = true; }, 6000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Discord OAuth outcomes --------------------------------------------------
|
||||||
|
// The link/login callback can only talk back to us through a redirect, so it
|
||||||
|
// lands on ?discord=<status>. Each status maps to [message, isError]; anything
|
||||||
|
// unrecognised is treated as a generic failure rather than shown raw.
|
||||||
|
const DISCORD_STATUS = {
|
||||||
|
linked: ["Discord connected.", false],
|
||||||
|
signedin: ["Signed in with Discord.", false],
|
||||||
|
created: ["Account created with Discord — welcome to Thermograph.", false],
|
||||||
|
cancelled: ["Discord sign-in cancelled.", false],
|
||||||
|
taken: ["That Discord account is already connected to another Thermograph account.", true],
|
||||||
|
mismatch: ["That email belongs to an account connected to a different Discord account. "
|
||||||
|
+ "Sign in with your password to change it.", true],
|
||||||
|
noemail: ["Discord didn't share an email address, so there's no account to sign in to.", true],
|
||||||
|
unverified: ["Verify your email address with Discord first, then try again.", true],
|
||||||
|
inactive: ["That account is deactivated.", true],
|
||||||
|
unavailable: ["Discord sign-in isn't available on this server.", true],
|
||||||
|
};
|
||||||
|
|
||||||
|
function showDiscordStatus(status) {
|
||||||
|
const [msg, isError] = DISCORD_STATUS[status]
|
||||||
|
|| ["Something went wrong with Discord. Please try again.", true];
|
||||||
|
showToast(msg, isError);
|
||||||
|
}
|
||||||
|
|
||||||
// --- boot --------------------------------------------------------------------
|
// --- boot --------------------------------------------------------------------
|
||||||
(async function initAccount() {
|
(async function initAccount() {
|
||||||
ensureAcctEl();
|
ensureAcctEl();
|
||||||
|
|
@ -462,10 +513,19 @@ function showToast(msg, isError = false) {
|
||||||
|
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
const token = params.get("verify_token");
|
const token = params.get("verify_token");
|
||||||
if (token) {
|
// Both of these are one-shot handoffs from a redirect; strip them together so a
|
||||||
|
// reload can't replay either, and so the two never fight over the URL.
|
||||||
|
const discordStatus = params.get("discord");
|
||||||
|
if (token || discordStatus) {
|
||||||
params.delete("verify_token");
|
params.delete("verify_token");
|
||||||
|
params.delete("discord");
|
||||||
const clean = location.pathname + (params.toString() ? `?${params}` : "") + location.hash;
|
const clean = location.pathname + (params.toString() ? `?${params}` : "") + location.hash;
|
||||||
history.replaceState(null, "", clean);
|
history.replaceState(null, "", clean);
|
||||||
|
}
|
||||||
|
// refreshUser() above already ran, and the callback's redirect carried the new
|
||||||
|
// session cookie, so the header is painted signed-in before this toast lands.
|
||||||
|
if (discordStatus) showDiscordStatus(discordStatus);
|
||||||
|
if (token) {
|
||||||
try {
|
try {
|
||||||
await verifyEmail(token);
|
await verifyEmail(token);
|
||||||
await refreshUser();
|
await refreshUser();
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,9 @@
|
||||||
--text: #e7ecf2;
|
--text: #e7ecf2;
|
||||||
--muted: #9aa6b2;
|
--muted: #9aa6b2;
|
||||||
--accent: #f0803c;
|
--accent: #f0803c;
|
||||||
|
/* Discord's brand blurple. Fixed in both schemes on purpose: it identifies the
|
||||||
|
provider, so it is their colour rather than one of ours to re-theme. */
|
||||||
|
--discord: #5865f2;
|
||||||
|
|
||||||
/* temperature grades: 9-step diverging cold -> green -> hot (colorblind-safe).
|
/* temperature grades: 9-step diverging cold -> green -> hot (colorblind-safe).
|
||||||
rec-* are the "Near Record" danger tiers — deliberately dark/saturated. */
|
rec-* are the "Near Record" danger tiers — deliberately dark/saturated. */
|
||||||
|
|
@ -422,6 +425,27 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; }
|
||||||
color: var(--text); border: 1px solid var(--rec-hot, #d64545);
|
color: var(--text); border: 1px solid var(--rec-hot, #d64545);
|
||||||
}
|
}
|
||||||
.acct-error[hidden] { display: none; }
|
.acct-error[hidden] { display: none; }
|
||||||
|
/* "or / Continue with Discord" under the submit button. Hidden unless the server
|
||||||
|
reports Discord configured (account.js sets [hidden]). */
|
||||||
|
.acct-alt { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.acct-alt[hidden] { display: none; }
|
||||||
|
.acct-or {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: .06em;
|
||||||
|
}
|
||||||
|
.acct-or::before, .acct-or::after {
|
||||||
|
content: ""; flex: 1; height: 1px; background: var(--border);
|
||||||
|
}
|
||||||
|
.acct-discord-login {
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: 9px;
|
||||||
|
padding: 12px 16px; min-height: 44px; border-radius: 10px;
|
||||||
|
background: var(--discord); color: #fff; font-weight: 700; font-size: 15px;
|
||||||
|
text-decoration: none; border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.acct-discord-login:hover { filter: brightness(1.08); }
|
||||||
|
.acct-discord-login svg { width: 20px; height: 15px; flex: none; }
|
||||||
|
|
||||||
|
.acct-pop-note { display: block; padding: 9px 12px; font-size: 13px; }
|
||||||
.acct-switch { margin: 0; font-size: 13px; color: var(--muted); text-align: center; }
|
.acct-switch { margin: 0; font-size: 13px; color: var(--muted); text-align: center; }
|
||||||
.acct-switch button {
|
.acct-switch button {
|
||||||
background: none; border: 0; color: var(--accent); cursor: pointer;
|
background: none; border: 0; color: var(--accent); cursor: pointer;
|
||||||
|
|
|
||||||
|
|
@ -210,10 +210,19 @@ THERMOGRAPH_BASE_URL=https://thermograph.org
|
||||||
# Discord allows ONE gateway connection per bot token, so enable this in exactly
|
# Discord allows ONE gateway connection per bot token, so enable this in exactly
|
||||||
# one environment — prod, the only vault with the token.
|
# one environment — prod, the only vault with the token.
|
||||||
#THERMOGRAPH_DISCORD_BOT=
|
#THERMOGRAPH_DISCORD_BOT=
|
||||||
# Account linking (OAuth2 "identify"): lets a signed-in user connect their Discord
|
# Discord as an identity (notifications/discord_link.py). One pair of credentials
|
||||||
# account, storing their Discord user id for DM alerts. CLIENT_ID is the same App
|
# gates BOTH flows, and setting them makes both appear in the UI at once:
|
||||||
# ID above. The CLIENT_SECRET is from OAuth2 -> Client Secret (a credential). In the
|
# - account linking (scope "identify") — a signed-in user connects their Discord
|
||||||
# portal, add the redirect: https://thermograph.org/api/v2/discord/link/callback
|
# account, storing their Discord user id for DM alerts;
|
||||||
|
# - sign in with Discord (scope "identify email") — an anonymous visitor
|
||||||
|
# authenticates, resolving or creating an account from the Discord identity.
|
||||||
|
# CLIENT_ID is the same App ID above. The CLIENT_SECRET is from OAuth2 -> Client
|
||||||
|
# Secret (a credential). In the portal, add the redirect:
|
||||||
|
# https://thermograph.org/api/v2/discord/link/callback
|
||||||
|
# Both flows come back to that one path (the purpose is carried in the signed
|
||||||
|
# state), so enabling sign-in needs no new redirect registered. Scopes are asked
|
||||||
|
# for per request, not registered, so the email scope needs no portal change
|
||||||
|
# either. An environment without these two vars simply shows no Discord UI.
|
||||||
#THERMOGRAPH_DISCORD_CLIENT_SECRET=
|
#THERMOGRAPH_DISCORD_CLIENT_SECRET=
|
||||||
# Gateway bot (opt-in): holds a live websocket so the bot replies to messages that
|
# Gateway bot (opt-in): holds a live websocket so the bot replies to messages that
|
||||||
# @mention it (or DM it) with a city grade — e.g. "@Thermograph Phoenix". Reuses
|
# @mention it (or DM it) with a city grade — e.g. "@Thermograph Phoenix". Reuses
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue