b0bf6fe770
Guild membership is now the whole gate: discordCallback checks membership
(and DISCORD_REQUIRED_ROLE when set), then Store.EnsureReader creates the
Reader on first sight and returns the same row on every later login. The
refusal returns before EnsureReader, so nothing is created as a side
effect of being turned away. OWNER_DISCORD_ID keeps seeding the owner, but
only as the administrator — it no longer gates sign-in.
The cutover grace path is gone with it: API_TOKEN, API_TOKEN_GRACE_UNTIL
and the legacy branch in httpmw.ResolveReader are deleted, so a credential
authenticates exactly one Reader or nothing. That also lets
userscript.Handler drop the re-derivation — the resolved path segment is
already the credential to substitute.
New surfaces: an empty library offers both install links instead of
describing a filter (listView.Fresh, which also hides the action key it has
nothing to name), and the owner alone gets a Readers panel with
POST /readers/{id}/revoke (404 for anyone else) to sign a Reader out
everywhere.
Isolation is asserted from both directions rather than by counting one
Reader's rows, and the shared-series invariant is pinned: two Readers on
one series produce one series row, two independent progresses, one poll
per due cycle, and one Reader's delete leaves the other's bookmark and the
poll intact.
321 lines
11 KiB
Go
321 lines
11 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"bookmarkmanager/backend/internal/session"
|
|
"bookmarkmanager/backend/internal/token"
|
|
)
|
|
|
|
const (
|
|
// oauthStateTTL bounds how long a started sign-in stays valid. Ten
|
|
// minutes is generous for Discord's round trip and short enough that a
|
|
// captured state is stale before it is worth replaying.
|
|
oauthStateTTL = 10 * time.Minute
|
|
// maxStates caps the state map so a flood of /auth/discord hits cannot
|
|
// grow memory; past the cap the oldest state is evicted, which at worst
|
|
// invalidates an in-flight sign-in.
|
|
maxStates = 256
|
|
// maxResponseBytes caps Discord API bodies; they are small, and an
|
|
// unbounded read is an OOM handed to Discord's CDN.
|
|
maxResponseBytes = 1 << 20
|
|
// discordTimeout keeps a hung Discord request from hanging the login
|
|
// callback forever.
|
|
discordTimeout = 15 * time.Second
|
|
)
|
|
|
|
// DiscordConfig is the OAuth application this service registers as, plus the
|
|
// guild that gates access.
|
|
type DiscordConfig struct {
|
|
ClientID string
|
|
ClientSecret string
|
|
GuildID string
|
|
// RequiredRole, when non-empty, is a role ID a member must hold on top of
|
|
// guild membership. Empty by default: membership alone suffices.
|
|
RequiredRole string
|
|
// APIBase is the Discord API root; configurable so tests run the whole
|
|
// flow against a local stub.
|
|
APIBase string
|
|
// RedirectURI is the full public URL of the callback — Discord requires
|
|
// the exact string, so it is configured, never derived from headers.
|
|
RedirectURI string
|
|
}
|
|
|
|
// oauthStates stores one-time sign-in states. A state is generated at
|
|
// /auth/discord, echoed back by Discord at the callback, and consumed there.
|
|
type oauthStates struct {
|
|
mu sync.Mutex
|
|
expiry map[string]time.Time
|
|
}
|
|
|
|
func newOAuthStates() *oauthStates {
|
|
return &oauthStates{expiry: make(map[string]time.Time)}
|
|
}
|
|
|
|
func (s *oauthStates) put(state string, expires time.Time) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
now := time.Now()
|
|
for k, at := range s.expiry {
|
|
if !at.After(now) {
|
|
delete(s.expiry, k)
|
|
}
|
|
}
|
|
// Evict the state closest to expiring when full, so a flood of starts
|
|
// cannot grow memory; at worst it invalidates an in-flight sign-in.
|
|
if len(s.expiry) >= maxStates {
|
|
var oldest string
|
|
var oldestAt time.Time
|
|
for k, at := range s.expiry {
|
|
if oldest == "" || at.Before(oldestAt) {
|
|
oldest, oldestAt = k, at
|
|
}
|
|
}
|
|
delete(s.expiry, oldest)
|
|
}
|
|
s.expiry[state] = expires
|
|
}
|
|
|
|
// take validates and consumes a state in one step: a state works exactly
|
|
// once, which is what makes a replayed callback useless.
|
|
func (s *oauthStates) take(state string) bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
expires, ok := s.expiry[state]
|
|
if !ok || !expires.After(time.Now()) {
|
|
return false
|
|
}
|
|
delete(s.expiry, state)
|
|
return true
|
|
}
|
|
|
|
// discordStart begins the authorization code grant: a fresh state, then a
|
|
// redirect to Discord's authorize page.
|
|
func (h *Handler) discordStart(w http.ResponseWriter, r *http.Request) {
|
|
state := session.NewID()
|
|
h.states.put(state, time.Now().Add(oauthStateTTL))
|
|
u := h.discord.APIBase + "/oauth2/authorize?" + url.Values{
|
|
"client_id": {h.discord.ClientID},
|
|
"redirect_uri": {h.discord.RedirectURI},
|
|
"response_type": {"code"},
|
|
"scope": {"identify guilds.members.read"},
|
|
"state": {state},
|
|
}.Encode()
|
|
http.Redirect(w, r, u, http.StatusSeeOther)
|
|
}
|
|
|
|
// discordCallback completes the grant: exchange the code, verify identity,
|
|
// membership and role, then mint a session. Every failure path renders the
|
|
// login page with an author-written message — nothing Discord supplied is
|
|
// ever interpolated into a page, and no secret reaches a log line.
|
|
func (h *Handler) discordCallback(w http.ResponseWriter, r *http.Request) {
|
|
ip := session.ClientIP(r)
|
|
if wait := h.limiter.RetryAfter(ip, time.Now()); wait > 0 {
|
|
secs := int(wait.Seconds()) + 1
|
|
w.Header().Set("Retry-After", strconv.Itoa(secs))
|
|
h.renderLogin(w, http.StatusTooManyRequests,
|
|
"Too many attempts. Try again in "+strconv.Itoa((secs+59)/60)+" min.")
|
|
return
|
|
}
|
|
|
|
// Discord refuses the grant (the reader hit cancel, or the application
|
|
// was misconfigured). The state is consumed so the flow is cleanly over;
|
|
// this makes no Discord calls, so it is not a failure the limiter counts.
|
|
if oerr := r.URL.Query().Get("error"); oerr != "" {
|
|
h.states.take(r.URL.Query().Get("state"))
|
|
h.renderLogin(w, http.StatusBadRequest, "Sign-in was cancelled.")
|
|
return
|
|
}
|
|
|
|
code := r.URL.Query().Get("code")
|
|
if code == "" || !h.states.take(r.URL.Query().Get("state")) {
|
|
h.limiter.Fail(ip, time.Now())
|
|
h.renderLogin(w, http.StatusBadRequest,
|
|
"This sign-in link was invalid or already used. Start again.")
|
|
return
|
|
}
|
|
|
|
tok, err := h.exchangeToken(r.Context(), code)
|
|
if err != nil {
|
|
h.limiter.Fail(ip, time.Now())
|
|
log.Printf("discord token exchange: %v", err)
|
|
h.renderLogin(w, http.StatusBadGateway,
|
|
"Discord sign-in is unavailable right now. Try again in a moment.")
|
|
return
|
|
}
|
|
|
|
userID, err := h.discordUserID(r.Context(), tok.AccessToken)
|
|
if err != nil {
|
|
h.limiter.Fail(ip, time.Now())
|
|
log.Printf("discord users/@me: %v", err)
|
|
h.renderLogin(w, http.StatusBadGateway,
|
|
"Discord sign-in is unavailable right now. Try again in a moment.")
|
|
return
|
|
}
|
|
member, isMember, err := h.discordMember(r.Context(), tok.AccessToken)
|
|
if err != nil {
|
|
h.limiter.Fail(ip, time.Now())
|
|
log.Printf("discord member check: %v", err)
|
|
h.renderLogin(w, http.StatusBadGateway,
|
|
"Discord sign-in is unavailable right now. Try again in a moment.")
|
|
return
|
|
}
|
|
// The refusal is the same for a non-member and a member without the
|
|
// required role, and it names neither the guild nor its id: an outsider
|
|
// cannot tell whether the guild exists, let alone which one gates.
|
|
//
|
|
// It also returns before EnsureReader, so a refused sign-in leaves no
|
|
// Reader row behind — the gate is the only thing standing between guild
|
|
// membership and a library.
|
|
if !isMember || (h.discord.RequiredRole != "" && !slices.Contains(member.Roles, h.discord.RequiredRole)) {
|
|
h.limiter.Fail(ip, time.Now())
|
|
h.renderLogin(w, http.StatusForbidden,
|
|
"This Discord account is not a member of this community.")
|
|
return
|
|
}
|
|
|
|
// Registration is the login (issue #27): first sight of a guild member
|
|
// creates their Reader, every later sight returns the same one. Their
|
|
// userscript credential is derived at epoch 0 the way the owner's is, so
|
|
// the install links work before they have read anything.
|
|
readerID, err := h.store.EnsureReader(userID, token.Hash(token.Token(h.tokenKey, userID, 0)))
|
|
if err != nil {
|
|
log.Printf("register reader: %v", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
h.limiter.Reset(ip)
|
|
sess, err := h.store.CreateSession(session.NewID(), readerID, session.SessionTTL)
|
|
if err != nil {
|
|
log.Printf("create session: %v", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
session.SetCookie(w, r, sess.ID)
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
}
|
|
|
|
// exchangeToken trades an authorization code for an access token. The body is
|
|
// form-encoded because that is what Discord accepts — it rejects a JSON
|
|
// payload — so the wire format is fixed here, not in a client library.
|
|
func (h *Handler) exchangeToken(ctx context.Context, code string) (discordToken, error) {
|
|
form := url.Values{
|
|
"client_id": {h.discord.ClientID},
|
|
"client_secret": {h.discord.ClientSecret},
|
|
"grant_type": {"authorization_code"},
|
|
"code": {code},
|
|
"redirect_uri": {h.discord.RedirectURI},
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
|
h.discord.APIBase+"/oauth2/token", strings.NewReader(form.Encode()))
|
|
if err != nil {
|
|
return discordToken{}, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.Header.Set("Accept", "application/json")
|
|
resp, err := h.httpClient.Do(req)
|
|
if err != nil {
|
|
return discordToken{}, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return discordToken{}, fmt.Errorf("status %d", resp.StatusCode)
|
|
}
|
|
var tok discordToken
|
|
if err := json.NewDecoder(io.LimitReader(resp.Body, maxResponseBytes)).Decode(&tok); err != nil {
|
|
return discordToken{}, err
|
|
}
|
|
if tok.AccessToken == "" {
|
|
return discordToken{}, errors.New("empty access token")
|
|
}
|
|
return tok, nil
|
|
}
|
|
|
|
// discordUserID fetches the signed-in user's id via the identify scope.
|
|
func (h *Handler) discordUserID(ctx context.Context, accessToken string) (string, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
|
|
h.discord.APIBase+"/users/@me", nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
|
req.Header.Set("Accept", "application/json")
|
|
resp, err := h.httpClient.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("status %d", resp.StatusCode)
|
|
}
|
|
var u struct {
|
|
ID string `json:"id"`
|
|
}
|
|
if err := json.NewDecoder(io.LimitReader(resp.Body, maxResponseBytes)).Decode(&u); err != nil {
|
|
return "", err
|
|
}
|
|
if u.ID == "" {
|
|
return "", errors.New("empty user id")
|
|
}
|
|
return u.ID, nil
|
|
}
|
|
|
|
type discordMember struct {
|
|
Roles []string `json:"roles"`
|
|
}
|
|
|
|
// discordMember fetches the current user's membership in the configured guild.
|
|
//
|
|
// This is the OAuth endpoint (Get Current User Guild Member), the one the
|
|
// guilds.members.read scope grants. Its bot-side twin, GET /guilds/{id}/
|
|
// members/{user}, reads almost identically and is the wrong one: it wants a
|
|
// Bot token and the application present in the guild, and answers a user
|
|
// Bearer token with 401 — which fails as an outage rather than a refusal, so
|
|
// nobody could sign in at all.
|
|
//
|
|
// A 404 or 403 (not in the guild, or the token lacks the scope) is a
|
|
// non-member, not an error.
|
|
func (h *Handler) discordMember(ctx context.Context, accessToken string) (discordMember, bool, error) {
|
|
u := h.discord.APIBase + "/users/@me/guilds/" +
|
|
url.PathEscape(h.discord.GuildID) + "/member"
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
|
if err != nil {
|
|
return discordMember{}, false, err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
|
req.Header.Set("Accept", "application/json")
|
|
resp, err := h.httpClient.Do(req)
|
|
if err != nil {
|
|
return discordMember{}, false, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusForbidden {
|
|
return discordMember{}, false, nil
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return discordMember{}, false, fmt.Errorf("status %d", resp.StatusCode)
|
|
}
|
|
var m discordMember
|
|
if err := json.NewDecoder(io.LimitReader(resp.Body, maxResponseBytes)).Decode(&m); err != nil {
|
|
return discordMember{}, false, err
|
|
}
|
|
return m, true, nil
|
|
}
|
|
|
|
type discordToken struct {
|
|
AccessToken string `json:"access_token"`
|
|
}
|