Files
felhom.eu/hub/internal/web/tokens.go
T
admin d62cc08b2e
gates / gates (push) Successful in 6m44s
hub v0.152.0: operator access tokens with scopes (09 §3 decision 251, operator 2026-10-15 11:38)
- Configuration → Access tokens: named, scoped (group read/write), expiring (≤365 d), shown once,
  stored SHA-256 only, every use logged by name. Bearer fhp_… on the web surface only; /api/v1 refuses it.
- Route table = the code's single source (web/routes.go), pinned to ServeHTTP's switch; session-only:
  secrets shown/minted, deletes/resets, password, tokens.
- R-952 closed: /api/v1 route table names each caller, pinned to the switch; no-credential refusal test.
- Fail-open fixed: an empty operator password served the UI open (main skipped RequireAuth). Live hub never open.
- 05 §17, 04 §6.2, 13, 09 §3 251; register 155→157 (+R-1054, R-1055, R-1056, −R-952); STATUS.
- website: prowlarr-logo.svg (byte copy from felhom.eu-media) — the site gate refused every push.
Not deployed — waits for the operator's yes.

Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-15 12:20:35 +02:00

260 lines
9.2 KiB
Go

package web
import (
"context"
"crypto/rand"
"encoding/base32"
"net"
"net/http"
"strconv"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// Operator access tokens (hub v0.152.0, `05` §17). The token rides `Authorization: Bearer fhp_…`. It is checked
// in RequireAuth BEFORE the session and Basic paths, against the route table in routes.go.
//
// WHY NO CSRF TOKEN FOR IT (`05` §16.1): a browser never attaches a bearer header by itself — not from a cookie,
// not from a cached credential — and a page on another site can set an Authorization header only through a CORS
// preflight, which the hub never answers. So a request that carries the token was written by whoever holds it.
// Pinned by TestToken_PostWithoutCSRFPasses and TestToken_CSRFRuleUnchangedForBasic.
// TokenPrefix marks a hub operator access token (the API refuses the same prefix, api/handler.go).
const TokenPrefix = store.APITokenPrefix
const (
tokenDefaultDays = 90
tokenMaxDays = 365
)
type tokenCtxKey struct{}
// requestToken returns the access token that authenticated r, or nil (session / Basic / unauthenticated).
func requestToken(r *http.Request) *store.APIToken {
t, _ := r.Context().Value(tokenCtxKey{}).(*store.APIToken)
return t
}
// newPlainToken mints fhp_ + 32 random bytes, base32 without padding (lower case, 52 chars).
func newPlainToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return TokenPrefix + strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b)), nil
}
// bearerToken returns the fhp_ token in r's Authorization header, or "".
func bearerToken(r *http.Request) string {
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
return ""
}
tok := strings.TrimSpace(strings.TrimPrefix(auth, "Bearer "))
if !strings.HasPrefix(tok, TokenPrefix) {
return ""
}
return tok
}
// clientAddr is where a request came from, for "last used from": the first X-Forwarded-For hop when the ingress
// set one, else the socket peer. Informational only — never an auth input.
func clientAddr(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
return strings.TrimSpace(strings.Split(xff, ",")[0])
}
if h, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return h
}
return r.RemoteAddr
}
// serveWithToken authenticates r by its access token and either serves it or refuses it. Every outcome is logged
// with the token's NAME (never the token): 401 for an unknown, revoked or expired token, 403 naming the missing
// scope or "session only".
func (s *Server) serveWithToken(w http.ResponseWriter, r *http.Request, next http.Handler, plain string) {
method, path := r.Method, r.URL.Path
t, err := s.store.GetAPITokenByPlain(plain)
if err != nil {
s.logger.Printf("[ERROR] access token lookup failed: %v", err)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
switch {
case t == nil:
s.logger.Printf("[WARN] access token refused: unknown token — %s %s from %s → 401", method, path, clientAddr(r))
http.Error(w, "Unauthorized: unknown access token", http.StatusUnauthorized)
return
case t.RevokedAt != nil:
s.logger.Printf("[WARN] access token %q refused: revoked — %s %s → 401", t.Name, method, path)
http.Error(w, "Unauthorized: access token revoked", http.StatusUnauthorized)
return
case t.Expired(time.Now()):
s.logger.Printf("[WARN] access token %q refused: expired %s — %s %s → 401", t.Name, t.ExpiresAt.UTC().Format(time.RFC3339), method, path)
http.Error(w, "Unauthorized: access token expired", http.StatusUnauthorized)
return
}
c := classifyRoute(method, path)
if !tokenAllows(t.Scopes, c) {
why := "session only — never by a token"
if !c.SessionOnly && c.Group != "" {
why = "missing scope " + c.scope()
}
s.logger.Printf("[WARN] access token %q refused: %s — %s %s → 403", t.Name, why, method, path)
http.Error(w, "Forbidden: "+why, http.StatusForbidden)
return
}
if err := s.store.TouchAPIToken(t.ID, clientAddr(r)); err != nil {
s.logger.Printf("[WARN] access token %q: last-used not recorded: %v", t.Name, err)
}
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r.WithContext(context.WithValue(r.Context(), tokenCtxKey{}, t)))
s.logger.Printf("[INFO] access token %q: %s %s → %d", t.Name, method, path, rec.status)
}
// statusRecorder keeps the status a handler wrote, for the token's log line.
type statusRecorder struct {
http.ResponseWriter
status int
wrote bool
}
func (s *statusRecorder) WriteHeader(code int) {
if !s.wrote {
s.status, s.wrote = code, true
}
s.ResponseWriter.WriteHeader(code)
}
func (s *statusRecorder) Write(b []byte) (int, error) {
s.wrote = true
return s.ResponseWriter.Write(b)
}
// handleTokenCreate creates a token from the Configuration card (session only — classified in routes.go, and this
// handler checks again so a table slip cannot open it). The plain token is rendered once in the response page.
func (s *Server) handleTokenCreate(w http.ResponseWriter, r *http.Request) {
if requestToken(r) != nil {
http.Error(w, "Forbidden: session only — never by a token", http.StatusForbidden)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
name := strings.TrimSpace(r.FormValue("name"))
if name == "" || len(name) > 64 {
http.Redirect(w, r, "/configuration?flash=token_name_invalid#tokens", http.StatusSeeOther)
return
}
var scopes []string
for _, sc := range r.Form["scope"] {
if !validScope(sc) {
http.Redirect(w, r, "/configuration?flash=token_scope_invalid#tokens", http.StatusSeeOther)
return
}
scopes = append(scopes, sc)
}
if len(scopes) == 0 {
http.Redirect(w, r, "/configuration?flash=token_no_scope#tokens", http.StatusSeeOther)
return
}
days, err := strconv.Atoi(strings.TrimSpace(r.FormValue("days")))
if r.FormValue("days") == "" {
days, err = tokenDefaultDays, nil
}
if err != nil || days < 1 || days > tokenMaxDays {
http.Redirect(w, r, "/configuration?flash=token_days_invalid#tokens", http.StatusSeeOther)
return
}
plain, err := newPlainToken()
if err != nil {
s.logger.Printf("[ERROR] access token: random source failed: %v", err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
expires := time.Now().Add(time.Duration(days) * 24 * time.Hour)
if _, err := s.store.CreateAPIToken(name, plain, scopes, "operator (session)", expires); err != nil {
s.logger.Printf("[ERROR] access token %q not created: %v", name, err)
http.Redirect(w, r, "/configuration?flash=token_create_failed#tokens", http.StatusSeeOther)
return
}
s.logger.Printf("[INFO] access token %q created from %s: scopes %s, expires %s", name, clientAddr(r), strings.Join(scopes, ","), expires.UTC().Format(time.RFC3339))
// Shown once: rendered into this response only, with no-store, never put in a redirect URL or a log.
w.Header().Set("Cache-Control", "no-store")
s.renderConfiguration(w, r, &createdToken{Name: name, Plain: plain})
}
// handleTokenRevoke revokes one token (session only, same double check).
func (s *Server) handleTokenRevoke(w http.ResponseWriter, r *http.Request, idStr string) {
if requestToken(r) != nil {
http.Error(w, "Forbidden: session only — never by a token", http.StatusForbidden)
return
}
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
if err := s.store.RevokeAPIToken(id); err != nil {
s.logger.Printf("[WARN] access token %d not revoked: %v", id, err)
http.Redirect(w, r, "/configuration?flash=token_revoke_failed#tokens", http.StatusSeeOther)
return
}
s.logger.Printf("[INFO] access token %d revoked from %s", id, clientAddr(r))
http.Redirect(w, r, "/configuration?flash=token_revoked#tokens", http.StatusSeeOther)
}
// createdToken is the one-time display of a fresh token on the Configuration page.
type createdToken struct{ Name, Plain string }
// tokenRow is one line of the Access tokens card.
type tokenRow struct {
ID int64
Name string
Scopes string
Expires string
LastUsed string
LastUsedFrom string
State string // "active" / "expired" / "revoked"
Active bool
}
func (s *Server) tokenRows() []tokenRow {
list, err := s.store.ListAPITokens()
if err != nil {
s.logger.Printf("[ERROR] access tokens list: %v", err)
return nil
}
now := time.Now()
var out []tokenRow
for _, t := range list {
row := tokenRow{ID: t.ID, Name: t.Name, Scopes: strings.Join(t.Scopes, ", "),
Expires: t.ExpiresAt.Local().Format("2006-01-02"), LastUsed: "never", LastUsedFrom: t.LastUsedFrom}
if t.LastUsedAt != nil {
row.LastUsed = t.LastUsedAt.Local().Format("2006-01-02 15:04")
}
switch {
case t.RevokedAt != nil:
row.State = "revoked"
case t.Expired(now):
row.State = "expired"
default:
row.State, row.Active = "active", true
}
out = append(out, row)
}
return out
}
// tokenDaysChoices are the expiry options of the create form; "never" is not offered.
var tokenDaysChoices = []int{30, tokenDefaultDays, 180, tokenMaxDays}
// Handler is the operator web surface as main mounts it: ServeHTTP behind RequireAuth, unconditionally.
func (s *Server) Handler() http.Handler {
return s.RequireAuth(http.HandlerFunc(s.ServeHTTP))
}