feat(backend): per-Reader userscript credential with UI install and rotation (#24)

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, so install URLs survive restarts while a database
leak yields nothing but hashes. One credential authenticates the script
download path and the API bearer header.

- internal/token: derivation + hashing; migration 0006 adds token_epoch
- seed refreshes the owner's epoch-0 hash only before first rotation
- httpmw.Auth resolves the acting Reader from the credential hash and
  stashes it in the request context; the retired API_TOKEN resolves to
  the owner until API_TOKEN_GRACE_UNTIL, 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 devices
  self-migrate on their next update poll
- web UI: Userscripts panel with session-gated install endpoints that
  render the script directly (credential never in markup, address bar
  or a redirect) and confirm-gated rotation; atomic epoch bump + hash
  rewrite in the store
- both userscripts carry __API_TOKEN__ placeholders; the committed
  global-token literal is removed (rotating at deploy retires it for
  real — it survives in git history)
- env: TOKEN_KEY required, API_TOKEN/API_TOKEN_GRACE_UNTIL retire the
  legacy credential; docs and compose updated
This commit is contained in:
2026-08-08 09:34:52 +07:00
parent bcc6b45515
commit 8f752ed86b
25 changed files with 1149 additions and 234 deletions
+59 -13
View File
@@ -2,7 +2,6 @@ package main
import (
"context"
"crypto/sha256"
"errors"
"log"
"net/http"
@@ -17,13 +16,25 @@ import (
"bookmarkmanager/backend/internal/httpmw"
"bookmarkmanager/backend/internal/latest"
"bookmarkmanager/backend/internal/store"
"bookmarkmanager/backend/internal/token"
"bookmarkmanager/backend/internal/userscript"
"bookmarkmanager/backend/internal/web"
)
// Config holds all runtime settings, sourced from environment variables.
type Config struct {
Token string
// Token is the retired global API token, kept only for the cutover grace
// window: while GraceUntil has not passed, it resolves to the owner
// Reader so already-installed scripts keep working. Unset after the
// window closes.
Token string
// TokenKey derives every Reader's userscript credential (internal/token).
// Required: without it no install URL can ever be built.
TokenKey string
// GraceUntil is the moment the retired global token stops resolving to
// the owner Reader. Zero means the token is already dead. Enforced in
// code on every request, not by a runbook note.
GraceUntil time.Time
AllowedOrigins []string
// DatabaseURL is the Postgres connection URL; required, no default,
// because a wrong guess would silently start on an empty database.
@@ -147,9 +158,29 @@ func loadLatestPoll() LatestPoll {
return p
}
// parseGraceUntil reads the retired-token deadline. Both a bare date and a
// full RFC3339 timestamp are accepted; an unparseable value is a
// configuration bug, not a gracefully-degraded feature — the whole point is
// that the window's end is enforced, so fail loud.
func parseGraceUntil(raw string) time.Time {
raw = strings.TrimSpace(raw)
if raw == "" {
return time.Time{}
}
for _, layout := range []string{time.RFC3339, "2006-01-02"} {
if t, err := time.Parse(layout, raw); err == nil {
return t
}
}
log.Fatalf("config: API_TOKEN_GRACE_UNTIL=%q is not a date (YYYY-MM-DD) or RFC3339 timestamp", raw)
return time.Time{}
}
func loadConfig() Config {
c := Config{
Token: os.Getenv("API_TOKEN"),
TokenKey: os.Getenv("TOKEN_KEY"),
GraceUntil: parseGraceUntil(os.Getenv("API_TOKEN_GRACE_UNTIL")),
DatabaseURL: os.Getenv("DATABASE_URL"),
Port: envOr("PORT", "8080"),
OwnerDiscordID: os.Getenv("OWNER_DISCORD_ID"),
@@ -183,23 +214,28 @@ func newRouter(s *store.Store, cfg Config) http.Handler {
// Outside httpmw.Auth (the updater sends no Authorization header) and
// outside the web UI's Discord auth (the script must be installable
// without a browser session). The path segment carries the token instead.
mux.HandleFunc("GET /u/{token}/manga-bookmark.user.js", userscript.Handler(cfg.Token, cfg.UserscriptPath))
mux.HandleFunc("GET /u/{token}/novel-bookmark.user.js", userscript.Handler(cfg.Token, cfg.NovelUserscriptPath))
// without a browser session). The path segment carries the credential
// instead, and the script is rendered with the resolved Reader's
// credential substituted in.
mux.HandleFunc("GET /u/{token}/manga-bookmark.user.js",
userscript.Handler(s, []byte(cfg.TokenKey), cfg.Token, cfg.GraceUntil, cfg.UserscriptPath))
mux.HandleFunc("GET /u/{token}/novel-bookmark.user.js",
userscript.Handler(s, []byte(cfg.TokenKey), cfg.Token, cfg.GraceUntil, cfg.NovelUserscriptPath))
h := &api.Handler{Store: s, ReaderID: s.OwnerID()}
h := &api.Handler{Store: s}
protected := http.NewServeMux()
protected.HandleFunc("GET /bookmarks", h.List)
protected.HandleFunc("PUT /bookmarks/{key}", h.Put)
protected.HandleFunc("DELETE /bookmarks/{key}", h.Delete)
auth := httpmw.Auth(cfg.Token, protected)
auth := httpmw.Auth(s, cfg.Token, cfg.GraceUntil, protected)
mux.Handle("/bookmarks", auth)
mux.Handle("/bookmarks/", auth)
// The browser UI is always registered; signing in is Discord OAuth, so
// there is no password to forget and no gate to leave unset.
wh, err := web.New(s, s.OwnerID(), cfg.Discord)
wh, err := web.New(s, s.OwnerID(), cfg.Discord, []byte(cfg.TokenKey),
cfg.UserscriptPath, cfg.NovelUserscriptPath)
if err != nil {
log.Fatalf("web handler: %v", err)
}
@@ -225,8 +261,8 @@ func guardEmptyUserscriptToken(next http.Handler) http.Handler {
func main() {
cfg := loadConfig()
if cfg.Token == "" {
log.Fatal("API_TOKEN is required")
if cfg.TokenKey == "" {
log.Fatal("TOKEN_KEY is required")
}
if cfg.OwnerDiscordID == "" {
log.Fatal("OWNER_DISCORD_ID is required")
@@ -246,10 +282,20 @@ func main() {
log.Fatalf("%s is required", key)
}
}
if cfg.Token == "" && !cfg.GraceUntil.IsZero() {
log.Fatal("API_TOKEN_GRACE_UNTIL is set but API_TOKEN is not")
}
if cfg.Token != "" && cfg.GraceUntil.IsZero() {
log.Printf("API_TOKEN is set without API_TOKEN_GRACE_UNTIL: the retired token is dead on arrival")
}
// The owner's userscript token is the global API token today (issue #22);
// the readers row carries its SHA-256, not the token itself.
owner := store.Owner{DiscordID: cfg.OwnerDiscordID, TokenHash: sha256.Sum256([]byte(cfg.Token))}
// The owner's userscript credential is derived from TOKEN_KEY at epoch 0
// (internal/token); the readers row carries its SHA-256, not the
// credential itself.
owner := store.Owner{
DiscordID: cfg.OwnerDiscordID,
TokenHash: token.Hash(token.Token([]byte(cfg.TokenKey), cfg.OwnerDiscordID, 0)),
}
s, err := store.Open(cfg.DatabaseURL, owner)
if err != nil {