feat(agent-auth): session tokens replace per-request scrypt (item 25)
Panel: after one scrypt-verified key auth, issue a random 24h session token; store only its SHA-256 hash + expiry on the agent record. authenticateAgent tries the token path first (constant-time hash compare, active + unexpired), then the original fingerprint / legacy VerifySecret path, and hands fresh tokens out via the X-Agent-Session-Token response headers. Revoke() now clears token material at all three revocation sites. Agent: persist the token in the config dir (0600, temp+rename like keys.json), present it as bearer, and on any 401 clear it and retry once with the key (which re-issues). Old panels never send the headers, so key-only operation is unchanged; old agents ignore the headers. Version stays 0.6.8. Tests: panel — issuance, hash-only storage, scrypt-free token path (sabotaged KeyHash still authenticates), expiry fallback + rotation, hash-mismatch fallback, revocation, per-record binding, key-only rotation. Agent — capture/persist/reuse against a fake panel, 401 fallback and re-issue, old-panel key-only, client-side expiry, double rejection surfacing without retry loops.
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
package main
|
||||
|
||||
// Item 25: agent-side session token handling. A fake panel (httptest)
|
||||
// issues tokens via response headers exactly like the real one; the
|
||||
// tests pin down capture, persistence, reuse, the 401 fallback to the
|
||||
// key, and old-panel behavior (no headers → key on every request).
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakePanel records the bearer of every request and answers like the
|
||||
// real panel: the key always authenticates (and issues issueToken
|
||||
// when non-empty), the current valid token authenticates, anything
|
||||
// else is a 401.
|
||||
type fakePanel struct {
|
||||
key string
|
||||
validToken string
|
||||
issueToken string
|
||||
bearers []string
|
||||
}
|
||||
|
||||
func (p *fakePanel) handler(w http.ResponseWriter, r *http.Request) {
|
||||
bearer := ""
|
||||
if h := r.Header.Get("Authorization"); len(h) > len("Bearer ") {
|
||||
bearer = h[len("Bearer "):]
|
||||
}
|
||||
p.bearers = append(p.bearers, bearer)
|
||||
switch bearer {
|
||||
case p.key:
|
||||
if p.issueToken != "" {
|
||||
w.Header().Set("X-Agent-Session-Token", p.issueToken)
|
||||
w.Header().Set("X-Agent-Session-Token-Expires", time.Now().Add(24*time.Hour).UTC().Format("2006-01-02T15:04:05.000Z"))
|
||||
}
|
||||
w.WriteHeader(200)
|
||||
fmt.Fprint(w, `{"ok":true}`)
|
||||
case p.validToken:
|
||||
w.WriteHeader(200)
|
||||
fmt.Fprint(w, `{"ok":true}`)
|
||||
default:
|
||||
w.WriteHeader(401)
|
||||
fmt.Fprint(w, `{"message":"Agent 身份校验失败"}`)
|
||||
}
|
||||
}
|
||||
|
||||
func setupTokenAgent(t *testing.T, panel *fakePanel) func() {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(panel.handler))
|
||||
oldCfg := cfg
|
||||
cfg.Controller = srv.URL
|
||||
cfg.AgentKey = panel.key
|
||||
cfg.SessionTokenFile = filepath.Join(t.TempDir(), "session-token.json")
|
||||
sessionTokenState.mu.Lock()
|
||||
oldState := sessionTokenState.value
|
||||
oldLoaded := sessionTokenState.loaded
|
||||
sessionTokenState.value = nil
|
||||
sessionTokenState.loaded = false
|
||||
sessionTokenState.mu.Unlock()
|
||||
return func() {
|
||||
srv.Close()
|
||||
cfg = oldCfg
|
||||
sessionTokenState.mu.Lock()
|
||||
sessionTokenState.value = oldState
|
||||
sessionTokenState.loaded = oldLoaded
|
||||
sessionTokenState.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionTokenCapturedPersistedAndReused(t *testing.T) {
|
||||
panel := &fakePanel{key: "the-key", issueToken: "tok-1", validToken: "tok-1"}
|
||||
defer setupTokenAgent(t, panel)()
|
||||
|
||||
if _, err := request("/api/agent/poll", []byte("{}")); err != nil {
|
||||
t.Fatalf("first request: %v", err)
|
||||
}
|
||||
if _, err := request("/api/agent/poll", []byte("{}")); err != nil {
|
||||
t.Fatalf("second request: %v", err)
|
||||
}
|
||||
want := []string{"the-key", "tok-1"}
|
||||
if fmt.Sprint(panel.bearers) != fmt.Sprint(want) {
|
||||
t.Fatalf("bearers = %v, want %v", panel.bearers, want)
|
||||
}
|
||||
// Persisted with keys.json-grade permissions.
|
||||
info, err := os.Stat(cfg.SessionTokenFile)
|
||||
if err != nil {
|
||||
t.Fatalf("token file not persisted: %v", err)
|
||||
}
|
||||
if perm := info.Mode().Perm(); perm != 0o600 {
|
||||
t.Fatalf("token file perm = %o, want 600", perm)
|
||||
}
|
||||
// And it survives a cold start: reset memory, it must load from disk.
|
||||
sessionTokenState.mu.Lock()
|
||||
sessionTokenState.value = nil
|
||||
sessionTokenState.loaded = false
|
||||
sessionTokenState.mu.Unlock()
|
||||
if tok := currentSessionToken(); tok == nil || tok.Token != "tok-1" {
|
||||
t.Fatalf("token not reloaded from disk: %+v", tok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionToken401FallsBackToKeyAndReissues(t *testing.T) {
|
||||
panel := &fakePanel{key: "the-key", issueToken: "tok-2", validToken: "tok-2"}
|
||||
defer setupTokenAgent(t, panel)()
|
||||
// Plant a stale token the panel no longer honors.
|
||||
storeSessionToken("tok-stale", time.Now().Add(time.Hour).UTC().Format("2006-01-02T15:04:05.000Z"))
|
||||
|
||||
if _, err := request("/api/agent/poll", []byte("{}")); err != nil {
|
||||
t.Fatalf("request with stale token: %v", err)
|
||||
}
|
||||
want := []string{"tok-stale", "the-key"}
|
||||
if fmt.Sprint(panel.bearers) != fmt.Sprint(want) {
|
||||
t.Fatalf("bearers = %v, want %v (token attempt, then key retry)", panel.bearers, want)
|
||||
}
|
||||
if tok := currentSessionToken(); tok == nil || tok.Token != "tok-2" {
|
||||
t.Fatalf("fresh token not stored after retry: %+v", tok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionTokenAbsentKeepsKeyAuth(t *testing.T) {
|
||||
// Old panel: never issues tokens.
|
||||
panel := &fakePanel{key: "the-key"}
|
||||
defer setupTokenAgent(t, panel)()
|
||||
for i := 0; i < 2; i++ {
|
||||
if _, err := request("/api/agent/poll", []byte("{}")); err != nil {
|
||||
t.Fatalf("request %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if fmt.Sprint(panel.bearers) != "[the-key the-key]" {
|
||||
t.Fatalf("bearers = %v, want key on every request", panel.bearers)
|
||||
}
|
||||
if _, err := os.Stat(cfg.SessionTokenFile); !os.IsNotExist(err) {
|
||||
t.Fatalf("no token file expected against an old panel, stat err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionTokenExpiredClientSideUsesKey(t *testing.T) {
|
||||
panel := &fakePanel{key: "the-key", issueToken: "tok-3", validToken: "tok-3"}
|
||||
defer setupTokenAgent(t, panel)()
|
||||
storeSessionToken("tok-old", time.Now().Add(-time.Hour).UTC().Format("2006-01-02T15:04:05.000Z"))
|
||||
if _, err := request("/api/agent/poll", []byte("{}")); err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
if len(panel.bearers) != 1 || panel.bearers[0] != "the-key" {
|
||||
t.Fatalf("bearers = %v, want the key used directly (no doomed token attempt)", panel.bearers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionTokenKeyRejectedSurfacesError(t *testing.T) {
|
||||
panel := &fakePanel{key: "the-real-key"}
|
||||
defer setupTokenAgent(t, panel)()
|
||||
cfg.AgentKey = "wrong-key"
|
||||
storeSessionToken("tok-stale", time.Now().Add(time.Hour).UTC().Format("2006-01-02T15:04:05.000Z"))
|
||||
if _, err := request("/api/agent/poll", []byte("{}")); err == nil {
|
||||
t.Fatal("expected an error when both token and key are rejected")
|
||||
}
|
||||
// Exactly two attempts: token, then key — never a loop.
|
||||
if len(panel.bearers) != 2 {
|
||||
t.Fatalf("attempts = %v, want exactly 2", panel.bearers)
|
||||
}
|
||||
if tok := currentSessionToken(); tok != nil {
|
||||
t.Fatalf("stale token should have been cleared, still held: %+v", tok)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user