package web import ( "context" "crypto/rand" "encoding/base32" "net" "net/http" "strconv" "strings" "time" "gitea.dooplex.hu/admin/felhom-hub/internal/accesslog" "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) } accesslog.Mark(r.Context(), "token") // R-1140: the KIND only — the name is in the line below, the token nowhere 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)) }