用 net/http/httptest 与可注入 Clock 固定时间实现带过期策略的签名 URL 校验

签名 URL(signed URL)在对象存储、下载站、付费资源分发里非常常见:服务端用密钥对「路径 + 过期时间」做 HMAC,把签名拼进查询串;客户端拿着这条 URL 在有效期内可以直接下载,过期后返回 403。真正难的不是签名算法本身,而是过期策略的可测试性——如果代码里直接写 time.Now(),你就要么 sleep 到过期(测试变慢且不稳定),要

签名 URL(signed URL)在对象存储、下载站、付费资源分发里非常常见:服务端用密钥对「路径 + 过期时间」做 HMAC,把签名拼进查询串;客户端拿着这条 URL 在有效期内可以直接下载,过期后返回 403。真正难的不是签名算法本身,而是过期策略的可测试性——如果代码里直接写 time.Now(),你就要么 sleep 到过期(测试变慢且不稳定),要么放弃对边界条件的验证。本文用 Go 标准库 net/http/httptest 加一个可注入的 Clock 接口,把时间变成测试参数,做到毫秒级、零 sleep、可并行的过期校验测试。

为什么要把时间抽象成接口

先看一段典型的坏味道代码:

func Verify(rawURL string) error {
    u, err := url.Parse(rawURL)
    if err != nil {
        return err
    }
    exp, _ := strconv.ParseInt(u.Query().Get("exp"), 10, 64)
    if time.Now().Unix() > exp { // 不可控
        return ErrExpired
    }
    // ... 校验签名
    return nil
}

问题有三点:第一,time.Now() 是隐式依赖,测试无法把「当前时间」拨到过期之后;第二,过期边界(> 还是 >=)只能靠猜;第三,时钟偏移、闰秒、时区这类问题在测试里完全暴露不出来。

把它改成显式依赖:

type Clock interface {
    Now() time.Time
}

type realClock struct{}

func (realClock) Now() time.Time { return time.Now() }

生产代码注入 realClock{},测试注入一个可以随意拨动的假时钟。这是 Go 里最常见的依赖注入方式——一个只有一个方法的接口,成本极低,收益极大。注意接口只暴露 Now(),不要顺手把 Since、After 也加进去,否则假实现会越来越臃肿。

经验法则:只要一段逻辑里出现了 time.Now()、rand.Intn、os.Getenv 这类全局函数,就把它抽成接口字段。测试的可控性与代码的耦合度成反比。

签名方案与参数设计

本文实现的方案如下,所有参数都显式定义,方便对照测试:

参数 位置 说明
path URL 路径 参与签名,防止换路径重放
exp query Unix 秒级过期时间戳
sig query hex 编码的 HMAC-SHA256,小写
key 服务端配置 共享密钥,至少 32 字节随机值

待签名字符串采用固定格式,避免拼接歧义:

msg := "GET\n" + path + "\n" + strconv.FormatInt(exp, 10)

把 HTTP 方法也纳入签名是个便宜的好习惯——同一路径的 GET 与 DELETE 不应该共享同一份凭证。字段之间用 \n 分隔,因为 URL 路径中不会出现裸换行,可以杜绝 a+bc 与 ab+c 拼接相同的问题。

比较签名时务必用 hmac.Equal,它内部是恒定时间比较,能挡住时序侧信道攻击;== 或 bytes.Equal 会在第一个不同字节处提前返回。

完整实现:Issuer + Verifier + 可注入时钟

先写核心包,注意 Verifier 把 Clock 作为结构体字段,而不是函数参数——这样中间件、handler 都能共用同一个实例。

package signedurl

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"net/url"
	"strconv"
	"strings"
	"time"
)

var (
	ErrMissingSig = errors.New("signedurl: missing sig parameter")
	ErrBadExp     = errors.New("signedurl: invalid exp parameter")
	ErrExpired    = errors.New("signedurl: url expired")
	ErrBadSig     = errors.New("signedurl: signature mismatch")
)

type Clock interface {
	Now() time.Time
}

type realClock struct{}

func (realClock) Now() time.Time { return time.Now() }

type Signer struct {
	key   []byte
	clock Clock
	ttl   time.Duration
}

func NewSigner(key []byte, clock Clock, ttl time.Duration) *Signer {
	if clock == nil {
		clock = realClock{}
	}
	return &Signer{key: key, clock: clock, ttl: ttl}
}

func (s *Signer) mac(method, path string, exp int64) string {
	h := hmac.New(sha256.New, s.key)
	h.Write([]byte(method))
	h.Write([]byte("\n"))
	h.Write([]byte(path))
	h.Write([]byte("\n"))
	h.Write([]byte(strconv.FormatInt(exp, 10)))
	return hex.EncodeToString(h.Sum(nil))
}

// Sign 为 path 生成带 exp 与 sig 的完整 URL。
func (s *Signer) Sign(method, path string) string {
	exp := s.clock.Now().Add(s.ttl).Unix()
	sig := s.mac(method, path, exp)
	return path + "?exp=" + strconv.FormatInt(exp, 10) + "&sig=" + sig
}

type Verifier struct {
	key   []byte
	clock Clock
}

func NewVerifier(key []byte, clock Clock) *Verifier {
	if clock == nil {
		clock = realClock{}
	}
	return &Verifier{key: key, clock: clock}
}

func (v *Verifier) Verify(method string, u *url.URL) error {
	q := u.Query()
	sig := q.Get("sig")
	if sig == "" {
		return ErrMissingSig
	}
	expStr := q.Get("exp")
	exp, err := strconv.ParseInt(expStr, 10, 64)
	if err != nil || exp <= 0 {
		return ErrBadExp
	}
	if v.clock.Now().Unix() > exp {
		return ErrExpired
	}
	want := v.mac(method, u.Path, exp)
	if !hmac.Equal([]byte(want), []byte(strings.ToLower(sig))) {
		return ErrBadSig
	}
	return nil
}

几个容易被忽略的细节:

  • Verify 接收 *url.URL 而不是原始字符串,避免调用方重复解析;同时用 u.Path 而不是 u.RequestURI(),后者包含查询串会让签名自我指涉。
  • 先判过期再判签名。签名校验需要一次 HMAC 计算,而过期判断是常数时间;攻击者拿大量随机 sig 打过来时,先挡掉过期请求能省下不少 CPU。安全上这没有信息泄露问题——过期与否本来就是公开的。
  • strings.ToLower 是为了兼容某些客户端把 hex 转成大写的情况;如果你的 SDK 自己可控,可以去掉这个宽容处理,严格按小写比较。
  • 错误用 errors.New 定义成哨兵值,测试里用 errors.Is 断言,不用比对错误字符串。

用 httptest 驱动 HTTP 层测试

核心逻辑有了,接下来把它接进 handler,并用 net/http/httptest 验证端到端行为。假时钟长这样:

type fakeClock struct {
	mu sync.Mutex
	t  time.Time
}

func newFakeClock(t time.Time) *fakeClock { return &fakeClock{t: t} }

func (c *fakeClock) Now() time.Time {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.t
}

func (c *fakeClock) Advance(d time.Duration) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.t = c.t.Add(d)
}

加 sync.Mutex 是为了让测试可以 t.Parallel();即使不并行,这个代价也可以忽略。

写 handler 与测试:

package signedurl

import (
	"errors"
	"net/http"
	"net/http/httptest"
	"net/url"
	"testing"
	"time"
)

func newHandler(v *Verifier) http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("/files/report.pdf", func(w http.ResponseWriter, r *http.Request) {
		if err := v.Verify(r.Method, r.URL); err != nil {
			switch {
			case errors.Is(err, ErrExpired):
				http.Error(w, "expired", http.StatusForbidden)
			case errors.Is(err, ErrMissingSig), errors.Is(err, ErrBadExp):
				http.Error(w, "bad request", http.StatusBadRequest)
			case errors.Is(err, ErrBadSig):
				http.Error(w, "forbidden", http.StatusForbidden)
			default:
				http.Error(w, "internal error", http.StatusInternalServerError)
			}
			return
		}
		w.Header().Set("Content-Type", "application/pdf")
		w.WriteHeader(http.StatusOK)
		_, _ = w.Write([]byte("%PDF-1.4 fake"))
	})
	return mux
}

func TestSignedURL_Expiry(t *testing.T) {
	t.Parallel()
	key := []byte("0123456789abcdef0123456789abcdef")
	base := time.Date(2024, 5, 1, 12, 0, 0, 0, time.UTC)
	clk := newFakeClock(base)

	signer := NewSigner(key, clk, 10*time.Minute)
	verifier := NewVerifier(key, clk)
	srv := httptest.NewServer(newHandler(verifier))
	defer srv.Close()

	signed := signer.Sign(http.MethodGet, "/files/report.pdf")
	target := srv.URL + signed

	// 有效期内
	resp, err := http.Get(target)
	if err != nil {
		t.Fatalf("get: %v", err)
	}
	resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("want 200, got %d", resp.StatusCode)
	}

	// 推进到刚好过期一秒
	clk.Advance(10*time.Minute + time.Second)
	resp, err = http.Get(target)
	if err != nil {
		t.Fatalf("get: %v", err)
	}
	resp.Body.Close()
	if resp.StatusCode != http.StatusForbidden {
		t.Fatalf("want 403, got %d", resp.StatusCode)
	}
}

注意 httptest.NewServer 会真的起一个监听端口,请求走完整的 TCP 栈,能验证 URL 解析、查询串转义这些细节;如果只关心 handler 逻辑,用 httptest.NewRecorder 更快:

req := httptest.NewRequest(http.MethodGet, signed, nil)
rec := httptest.NewRecorder()
newHandler(verifier).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
	t.Fatalf("want 200, got %d body=%s", rec.Code, rec.Body.String())
}

两者分工:NewRecorder 用于表驱动的大量用例,NewServer 用于少数需要验证真实 URL 语义的冒烟测试。上面的过期测试其实用 NewRecorder 就够了,因为签名逻辑不依赖网络。

边界与安全测试清单

有了固定时钟,边界用例写起来非常顺。下面这张表建议逐条覆盖,用 t.Run 组织成子测试:

用例 构造方式 期望结果
过期前一秒 Advance(ttl – 1s) 200
恰好过期 Advance(ttl) 403(因 > 判定,恰好相等仍有效)
过期后一秒 Advance(ttl + 1s) 403
篡改 path 把 /files/report.pdf 换成 /files/secret.pdf 403
篡改 exp 变大 手工改 exp 403
缺失 sig 去掉 sig 400
exp 非数字 exp=abc 400
换密钥签发 用另一把 key 调 Signer 403
大小写 sig strings.ToUpper(sig) 200(按实现兼容)

「恰好过期」这一行值得单独强调:Now().Unix() > exp 与 >= exp 语义差一秒,是实现里最常被改错的地方。必须在测试里钉死。如果团队约定「exp 表示最后一个有效的整秒」,那就用 >;如果约定「exp 时刻即失效」,就用 >=。两种都对,但必须与文档一致并被测试覆盖。

篡改类用例的写法示例:

func TestSignedURL_Tamper(t *testing.T) {
	key := []byte("0123456789abcdef0123456789abcdef")
	base := time.Date(2024, 5, 1, 12, 0, 0, 0, time.UTC)
	clk := newFakeClock(base)
	signer := NewSigner(key, clk, time.Minute)
	verifier := NewVerifier(key, clk)

	signed := signer.Sign(http.MethodGet, "/files/report.pdf")
	u, err := url.Parse(signed)
	if err != nil {
		t.Fatal(err)
	}
	q := u.Query()

	// 篡改 path
	u2 := *u
	u2.Path = "/files/secret.pdf"
	if err := verifier.Verify(http.MethodGet, &u2); !errors.Is(err, ErrBadSig) {
		t.Fatalf("tampered path: want ErrBadSig, got %v", err)
	}

	// 篡改 exp 为更大的值
	q.Set("exp", strconv.FormatInt(clk.Now().Add(time.Hour).Unix(), 10))
	u3 := *u
	u3.RawQuery = q.Encode()
	if err := verifier.Verify(http.MethodGet, &u3); !errors.Is(err, ErrBadSig) {
		t.Fatalf("tampered exp: want ErrBadSig, got %v", err)
	}
}

用 u2 := *u 做浅拷贝是安全的,因为 url.URL 的字段都不在拷贝后被并发修改的路径上;但改 RawQuery 前要重新 q.Encode(),否则手工拼的查询串顺序不稳定。

常见坑与排查

坑一:真实时钟与假时钟混用。如果 handler 内部某处又调了 time.Now(),测试会看起来「有时通过有时失败」。排查方法是给 signedurl 包加个 grep -rn "time.Now()" 的 CI 检查,除了 realClock.Now 之外不允许出现。

坑二:hmac.Equal 参数顺序。它的两个参数是对称的,但如果你不小心写成 hmac.Equal([]byte(sig), []byte(want)) 也照样工作——真正的问题是有人为了「优化」提前 len(sig) != len(want) 直接返回,这会泄露长度信息。长度不同本来就由 hmac.Equal 处理,不用手写。

坑三:url.Query() 丢参数。u.Query() 返回的是解析后的 url.Values,如果客户端传了重复的 exp 参数,Get 只取第一个。某些网关会做参数合并,导致签名串与服务端计算的不一致。稳妥做法是签名时对参数做规范化排序,而不是依赖解析结果。

坑四:时区。Unix() 返回的是绝对秒数,与时区无关,这点没问题;但如果你在日志里打印 exp 对应的时间,记得 time.Unix(exp, 0).UTC(),否则本地时区会让排查产生错觉。

坑五:httptest.NewServer 未 Close。测试多跑几次会出现 listen tcp 127.0.0.1:xxxxx: bind: address already in use——其实是端口泄漏到了 too many open files。永远 defer srv.Close(),或者在 TestMain 里统一管理。

典型报错信息与对应原因:

  • signedurl: signature mismatch 但签名端日志看起来没问题 → 检查是否在 Sign 与 Verify 之间对 URL 做了 url.QueryEscape 二次编码。
  • signedurl: invalid exp parameter → 客户端把 exp 当成了 RFC3339 字符串传,约定好只用 Unix 秒。
  • 测试偶发 403 → 假时钟被多个 goroutine 共享但没加锁,或者测试里忘了 Advance 前先 Reset。

小结

把时间抽象成 Clock 接口,配合 net/http/httptest,签名 URL 的过期策略就从一个「只能靠 sleep 碰运气」的模糊地带,变成了可以用表驱动逐条钉死的确定性逻辑。核心动作只有三步:定义单方法接口、生产注入真实时钟、测试注入可拨动的假时钟。代价是几十行代码,收益是测试从秒级降到毫秒级、可并行、边界清晰,同时倒逼你把散落的 time.Now() 收敛到一处。

FAQ

Q:为什么不直接用 github.com/benbjohnson/clock 或 clockwork?
A:完全可以,它们提供了 Add、After、Ticker 等更丰富的假时钟能力。但如果你的代码只需要 Now(),引入一个依赖就为了一个方法并不划算;标准库一行接口加上二十行假实现,维护成本更低。等真的需要测试 Ticker 驱动的清理协程时再换库也不迟。

Q:能否用 time.Now().Add(-time.Hour) 之类的办法模拟过期?
A:只有当被测代码愿意接受「基准时间」参数时才可行。把基准时间做成函数的可选参数会让 API 变丑,且中间件层无处传递。接口注入是更干净的做法。

Q:签名 URL 应该用 HMAC 还是非对称签名?
A:单服务、单密钥场景 HMAC-SHA256 足够快且实现简单。如果签发方与校验方是不同团队、需要让校验方无法伪造 URL,则应换成 Ed25519 私钥签名、公钥校验,此时 Signer 与 Verifier 的密钥字段要改成 ed25519.PrivateKey 与 ed25519.PublicKey,其余结构不变。

Q:TTL 设多长合适?
A:下载类资源通常 5~15 分钟足够;视频分片场景可以到 1~6 小时,但要配合 CDN 的回源鉴权。TTL 越长,泄露 URL 被滥用的窗口越大。另外要保证 TTL 远大于客户端与服务端的时钟漂移(一般 NTP 同步后在毫秒级,留出 60 秒余量绰绰有余)。

Q:过期后返回 403 还是 410?
A:返回 403 更常见,且不会向攻击者区分「不存在」与「已过期」;如果业务希望客户端自动刷新签名,可以在响应体里带一个机器可读的错误码字段,状态码保持不变。