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:
+59
-13
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user