d62cc08b2e
gates / gates (push) Successful in 6m44s
- 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
260 lines
9.2 KiB
Go
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))
|
|
}
|