签名 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 更常见,且不会向攻击者区分「不存在」与「已过期」;如果业务希望客户端自动刷新签名,可以在响应体里带一个机器可读的错误码字段,状态码保持不变。