Files
mangaBookmark/backend/internal/httpmw/middleware.go
T
sulthan 27cf0955de Per-Reader userscript credential with UI install and rotation (#24) (#32)
Closes #24. Child of #18; based on current main (includes Postgres, Reader table, Discord OAuth).

## What

Each Reader's userscript credential is derived from `TOKEN_KEY`, their Discord id and a token epoch (HMAC-SHA256, hex); only its SHA-256 sits in `readers.token_sha256` (new `token_epoch` column, migration 0006). One credential authenticates the script download path and the API bearer header.

- `internal/token`: derivation + hashing; the seed refreshes the owner's epoch-0 hash only before first rotation, so a restart can never resurrect a rotated-away credential
- `httpmw.Auth`/`ResolveReader`: acting Reader resolved from the credential hash, stashed in request context; the retired global `API_TOKEN` resolves to the owner until `API_TOKEN_GRACE_UNTIL` (enforced in code, logged per use) on both the bearer and script-download paths
- Userscript handler renders the bindmounted file with the resolved Reader's credential substituted for `__API_TOKEN__`; a legacy-path request during grace serves the derived credential, so installed devices self-migrate on their next update poll
- Web UI: "Userscripts" panel — session-gated install endpoints render the script directly (credential never in markup, address bar, or a redirect), confirm-gated rotation with an atomic epoch bump + hash rewrite and a reinstall warning
- Both userscripts carry `__API_TOKEN__` placeholders; the committed global-token literal is removed

## Design note

Credentials are derived rather than stored-random because the server must rebuild install URLs after restarts while the DB holds only hashes. HMAC output is high-entropy and unbrute-forceable; the AC's intent (unguessable, DB-leak-proof) is met.

## Deploy (also in DEPLOY.md)

1. Add `TOKEN_KEY` (`openssl rand -hex 32`) — required; changing it later invalidates every credential.
2. Keep `API_TOKEN` + set `API_TOKEN_GRACE_UNTIL` for the 14-day window.
3. After deploy, sign in → Userscripts → reinstall both scripts on every device. This also retires the old global credential for real — its literal survives in git history (present since 0ef5286), so rotation is what kills it.

## Verification

- Full Go suite green against real Postgres per test; userscript JS suite 45/45
- New router-level tests: per-Reader isolation (read/write/delete), grace expiry on bearer + script path, self-migrating legacy path, install serving, rotation (old cred 401/404, new cred works, install renders new credential), app page leaks no credential
- Store tests: hash lookup, token info, atomic rotation with stale-epoch rejection, rotation survives restart
- Live smoke of the built binary: grace acceptance logged, derived auth, substitution, restart resilience, stored hash = SHA-256 of derived credential

Reviewed-on: #32
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
2026-08-08 14:54:03 +07:00

160 lines
5.2 KiB
Go

package httpmw
import (
"compress/gzip"
"context"
"crypto/subtle"
"log"
"net/http"
"strings"
"time"
"bookmarkmanager/backend/internal/store"
"bookmarkmanager/backend/internal/token"
)
const bearerPrefix = "Bearer "
type ctxKey int
// readerCtxKey is where Auth stashes the authenticated Reader id.
const readerCtxKey ctxKey = iota
// ReaderID returns the Reader id Auth authenticated, for handlers that take
// the acting Reader from the request rather than from a fixed field.
func ReaderID(r *http.Request) int64 { return r.Context().Value(readerCtxKey).(int64) }
// ResolveReader maps a presented credential to a Reader. The credential is
// hashed and matched against readers.token_sha256 — an equality on 32-byte
// values, never a comparison of the credential itself — and, during the
// cutover window, the retired global token resolves to the owner. Every
// legacy acceptance is logged so the window can be confirmed empty before
// the token is removed. The same resolution backs the API bearer header and
// the userscript download path, so the window covers both.
func ResolveReader(s *store.Store, legacy string, graceUntil time.Time, cred string) (int64, bool) {
if readerID, ok, err := s.ReaderIDForTokenHash(token.Hash(cred)); err != nil {
log.Printf("auth: reader lookup: %v", err)
return 0, false
} else if ok {
return readerID, true
}
if legacy != "" && time.Now().Before(graceUntil) &&
subtle.ConstantTimeCompare([]byte(cred), []byte(legacy)) == 1 {
log.Printf("auth: retired global token accepted for owner reader %d (grace until %s)",
s.OwnerID(), graceUntil.Format(time.RFC3339))
return s.OwnerID(), true
}
return 0, false
}
// Auth guards a handler with a per-Reader bearer credential. The acting
// Reader travels in the request context, so a handler scopes every store call
// to exactly the Reader that authenticated.
func Auth(s *store.Store, legacy string, graceUntil time.Time, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := r.Header.Get("Authorization")
if !strings.HasPrefix(h, bearerPrefix) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
readerID, ok := ResolveReader(s, legacy, graceUntil, strings.TrimPrefix(h, bearerPrefix))
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), readerCtxKey, readerID)))
})
}
// gzipWriter compresses the body and drops Content-Length, which no longer
// describes what goes on the wire. WriteHeader is where the decision is made:
// only then is Content-Type known.
type gzipWriter struct {
http.ResponseWriter
gz *gzip.Writer
decided bool
}
// compressible covers what this server actually serves in bulk: HTML, CSS, JS
// and JSON. Fonts are woff2, which is already compressed — gzipping them costs
// CPU to add bytes.
func compressible(contentType string) bool {
ct, _, _ := strings.Cut(contentType, ";")
switch strings.TrimSpace(ct) {
case "text/html", "text/css", "text/javascript", "application/javascript",
"application/json", "text/plain":
return true
}
return false
}
func (w *gzipWriter) WriteHeader(status int) {
if !w.decided {
w.decided = true
if compressible(w.Header().Get("Content-Type")) {
w.Header().Set("Content-Encoding", "gzip")
w.Header().Del("Content-Length")
w.gz = gzip.NewWriter(w.ResponseWriter)
}
}
w.ResponseWriter.WriteHeader(status)
}
func (w *gzipWriter) Write(b []byte) (int, error) {
if !w.decided {
w.WriteHeader(http.StatusOK)
}
if w.gz != nil {
return w.gz.Write(b)
}
return w.ResponseWriter.Write(b)
}
// Gzip compresses text responses for clients that ask. The templates,
// stylesheet and htmx together are ~120 KB uncompressed and roughly a quarter
// of that gzipped, which is the difference between a fast and a slow first load
// on mobile data.
func Gzip(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
next.ServeHTTP(w, r)
return
}
w.Header().Add("Vary", "Accept-Encoding")
gw := &gzipWriter{ResponseWriter: w}
defer func() {
if gw.gz != nil {
gw.gz.Close()
}
}()
next.ServeHTTP(gw, r)
})
}
// CORS reflects the request Origin only when it is in allowed, answers
// preflight OPTIONS with 204, and passes everything else through. It wraps the
// auth middleware so preflight (which carries no Authorization header) is never
// rejected by auth.
func CORS(allowed []string, next http.Handler) http.Handler {
set := make(map[string]struct{}, len(allowed))
for _, o := range allowed {
set[o] = struct{}{}
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if _, ok := set[origin]; ok && origin != "" {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Add("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Methods", "GET,PUT,DELETE,OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Authorization,Content-Type")
w.Header().Set("Access-Control-Max-Age", "86400")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}