认证与授权不是一件事
认证回答“你是谁”,授权回答“你能对这个资源做什么”。这两件事经常被塞进同一个中间件,结果是一个函数既解析令牌又判断归属,最后没人说得清某条拒绝到底来自签名失败、令牌过期,还是客户 ID 不匹配。拆分之后,认证只产出经过验证的主体声明,授权只看声明与资源,两边都可以单独测试。
更隐蔽的问题在服务之间。订单服务调用库存服务时,很多系统只带一个“内部令牌”,库存服务看到令牌有效就放行。这等于把“在内网”当成了身份:任何能发请求的进程都拥有全部权限。一旦某个服务被攻破,攻击者不需要提权,直接沿着内部调用横向移动。
本节在 arch-lab 里把两类主体显式建模:用户身份(User)和服务身份(Service)。服务身份不代表它自己拥有资源,它只能携带 Actor 代表某个用户行事。授权策略同时检查动作范围与资源归属,任何“服务默认可看全部订单”的分支都不存在。零信任的背景可以回看 零信任架构
,这里只落到订单 API 的可执行策略。
两个主体,三种声明
arch-lab 的令牌只保留授权需要的事实:
| 字段 | 用户令牌 | 服务令牌 | 用途 |
|---|---|---|---|
sub | cus_1001 | order-worker | 谁在调用 |
kind | user | service | 主体类型 |
act | 空 | cus_1001 | 服务代表谁 |
scp | orders:read | orders:read | 允许的动作 |
exp | 5 分钟 | 5 分钟 | 过期时间 |
用户令牌的 sub 就是客户 ID;服务令牌的 sub 是工作负载名,act 才是客户 ID。把两者混成一个字段,会让“订单属于谁”和“谁发起了调用”这两件事无法区分。审计日志要记录的是 sub=order-worker, act=cus_1001,而不是一个含糊的 customer_id。
范围(scope)只写动作,不写资源 ID。orders:read 表示可以读订单,能读哪一张由归属校验决定。把资源 ID 编进 scope(如 orders:read:ord_01)会让令牌数量爆炸,也会把授权逻辑散落到签发方。
令牌:HMAC 自签,不引入 JWT 库
真实系统可以用 JWT、PASETO 或平台签发的短期凭证。这里用 HMAC-SHA256 自签,是为了把“签名覆盖什么、验证方复算什么”暴露出来,而不是推荐手写令牌格式。载荷是 JSON,签名覆盖 base64url 编码后的载荷,验证时先验签再解析,顺序不能反。
// file: internal/access/token.go
// tier: L1
// verify: go test ./internal/access/
package access
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"strings"
"time"
)
// Kind 区分两类主体:真实用户,以及代表某个用户行事的服务。
// 服务身份没有“自己的订单”,它只能以 Actor 的名义访问资源。
type Kind string
const (
User Kind = "user"
Service Kind = "service"
)
var (
ErrMalformed = errors.New("access: malformed token")
ErrSignature = errors.New("access: bad signature")
ErrExpired = errors.New("access: token expired")
)
type Claims struct {
Subject string `json:"sub"`
Kind Kind `json:"kind"`
Actor string `json:"act,omitempty"`
Scopes []string `json:"scp"`
IssuedAt int64 `json:"iat"`
ExpiresAt int64 `json:"exp"`
TokenID string `json:"jti"`
}
func (c Claims) HasScope(want string) bool {
for _, s := range c.Scopes {
if s == want {
return true
}
}
return false
}
// Issuer 用 HMAC-SHA256 演示签名,不引入 JWT 库:载荷自描述,
// 签名覆盖 base64 后的载荷,验证方用同一密钥复算。
type Issuer struct {
key []byte
ttl time.Duration
now func() time.Time
}
func NewIssuer(key []byte, ttl time.Duration) *Issuer {
return &Issuer{key: key, ttl: ttl, now: time.Now}
}
func (i *Issuer) Issue(c Claims) (string, error) {
if c.Subject == "" || (c.Kind != User && c.Kind != Service) || len(c.Scopes) == 0 {
return "", ErrMalformed
}
c.IssuedAt = i.now().Unix()
c.ExpiresAt = c.IssuedAt + int64(i.ttl/time.Second)
body, err := json.Marshal(c)
if err != nil {
return "", err
}
payload := base64.RawURLEncoding.EncodeToString(body)
return payload + "." + i.sign(payload), nil
}
func (i *Issuer) sign(payload string) string {
mac := hmac.New(sha256.New, i.key)
mac.Write([]byte(payload))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
func (i *Issuer) Verify(raw string) (Claims, error) {
var zero Claims
payload, sig, ok := strings.Cut(raw, ".")
if !ok {
return zero, ErrMalformed
}
if !hmac.Equal([]byte(sig), []byte(i.sign(payload))) {
return zero, ErrSignature
}
body, err := base64.RawURLEncoding.DecodeString(payload)
if err != nil {
return zero, ErrMalformed
}
var c Claims
if err := json.Unmarshal(body, &c); err != nil {
return zero, ErrMalformed
}
if c.Subject == "" || (c.Kind != User && c.Kind != Service) {
return zero, ErrMalformed
}
if !i.now().Before(time.Unix(c.ExpiresAt, 0)) {
return zero, ErrExpired
}
return c, nil
}
三个细节值得强调。第一,先验签再解析 JSON:解析不可信输入本身就可能触发深层嵌套或超大分配,虽然 encoding/json 有基本保护,但顺序仍是原则。第二,过期判断用 !now.Before(exp),等于把 exp 那一秒也视为过期,避免边界上的“刚好还有效”。第三,签发方校验 Subject、Kind 和 Scopes,避免签出没有主体或没有范围的空令牌。
策略:归属校验写成纯函数
策略层不依赖 http.Request,只接受 Claims、Action 和 Resource。这样同一套规则既能给 HTTP 中间件用,也能给消息消费者、定时任务和测试用。返回值带 Reason,但对外只返回统一的 403,原因写进审计日志,不返回给调用方。
// file: internal/access/policy.go
// tier: L1
// verify: go test ./internal/access/
package access
import "fmt"
type Action string
const (
ReadOrder Action = "order:read"
CancelOrder Action = "order:cancel"
ListOrders Action = "order:list"
)
// Resource 只描述授权需要的最小事实,避免把整个订单聚合塞进策略层。
type Resource struct {
Type string
ID string
Customer string
}
type Decision struct {
Allow bool
Reason string
}
var actionScopes = map[Action]string{
ReadOrder: "orders:read",
CancelOrder: "orders:cancel",
ListOrders: "orders:read",
}
// Authorize 同时检查动作范围与资源归属。服务身份必须携带 Actor,
// 且 Actor 就是资源所有者;不存在“服务默认能看所有订单”的分支。
func Authorize(c Claims, a Action, r Resource) Decision {
scope, known := actionScopes[a]
if !known {
return Decision{Allow: false, Reason: "unknown action"}
}
if r.Type != "order" {
return Decision{Allow: false, Reason: "unknown resource type"}
}
if !c.HasScope(scope) {
return Decision{Allow: false, Reason: fmt.Sprintf("missing scope %q", scope)}
}
switch c.Kind {
case User:
if c.Subject != r.Customer {
return Decision{Allow: false, Reason: "subject is not the order owner"}
}
case Service:
if c.Actor == "" {
return Decision{Allow: false, Reason: "service call must carry an actor"}
}
if c.Actor != r.Customer {
return Decision{Allow: false, Reason: "actor is not the order owner"}
}
default:
return Decision{Allow: false, Reason: "unknown principal kind"}
}
return Decision{Allow: true, Reason: "owner with required scope"}
}
策略里没有 orders:admin。批量对账、客服代操作这类场景需要单独的管理端点、单独的身份和单独的审计,不能靠给普通订单接口加一个万能 scope 解决。万能 scope 一旦存在,所有服务都会申请它,最小权限立刻失效。
资源对象由处理器在加载订单后填充。顺序必须是“先取订单,再授权”,而不是“先看路径里的客户 ID”。订单的真实归属来自数据库,路径参数和请求体都不可信。如果订单不存在,直接返回 404,不要先返回 403 再暴露订单是否存在。
越权测试:九个用例
测试分两组。令牌组覆盖往返、篡改载荷、错误密钥、畸形输入和过期;策略组覆盖归属与服务代调用。表驱动用例的 reason 用子串匹配,避免把完整文案写死,但保留了“拒绝原因必须指向正确维度”的约束。
// file: internal/access/access_test.go
// tier: L1
// verify: go test ./internal/access/
package access
import (
"errors"
"strings"
"testing"
"time"
)
var fixedNow = time.Date(2026, 10, 10, 9, 0, 0, 0, time.UTC)
func TestTokenRoundTripAndFailures(t *testing.T) {
issuer := NewIssuer([]byte("demo-secret"), 5*time.Minute)
issuer.now = func() time.Time { return fixedNow }
raw, err := issuer.Issue(Claims{
Subject: "cus_1001", Kind: Service, Actor: "cus_1001",
Scopes: []string{"orders:read"}, TokenID: "jti-1",
})
if err != nil {
t.Fatalf("issue: %v", err)
}
got, err := issuer.Verify(raw)
if err != nil {
t.Fatalf("verify: %v", err)
}
if got.Subject != "cus_1001" || got.Actor != "cus_1001" || !got.HasScope("orders:read") {
t.Fatalf("claims lost in round trip: %+v", got)
}
if got.ExpiresAt-got.IssuedAt != 300 {
t.Fatalf("ttl = %d seconds, want 300", got.ExpiresAt-got.IssuedAt)
}
tampered := raw[:len(raw)-1] + "A"
if _, err := issuer.Verify(tampered); !errors.Is(err, ErrSignature) {
t.Fatalf("tampered payload err = %v, want ErrSignature", err)
}
other := NewIssuer([]byte("another-secret"), time.Minute)
if _, err := other.Verify(raw); !errors.Is(err, ErrSignature) {
t.Fatalf("foreign key err = %v, want ErrSignature", err)
}
if _, err := issuer.Verify("no-dot-here"); !errors.Is(err, ErrMalformed) {
t.Fatalf("malformed err = %v, want ErrMalformed", err)
}
issuer.now = func() time.Time { return fixedNow.Add(6 * time.Minute) }
if _, err := issuer.Verify(raw); !errors.Is(err, ErrExpired) {
t.Fatalf("expired err = %v, want ErrExpired", err)
}
}
func TestAuthorizeOwnershipAndScopes(t *testing.T) {
order := Resource{Type: "order", ID: "ord_01", Customer: "cus_1001"}
owner := Claims{Subject: "cus_1001", Kind: User, Scopes: []string{"orders:read", "orders:cancel"}}
other := Claims{Subject: "cus_2002", Kind: User, Scopes: []string{"orders:read"}}
onBehalf := Claims{Subject: "order-worker", Kind: Service, Actor: "cus_1001", Scopes: []string{"orders:read"}}
batch := Claims{Subject: "order-worker", Kind: Service, Scopes: []string{"orders:read"}}
cases := []struct {
name string
claims Claims
action Action
res Resource
allow bool
reason string
}{
{"owner reads own order", owner, ReadOrder, order, true, "owner"},
{"customer reads someone else order", other, ReadOrder, order, false, "order owner"},
{"owner without read scope", Claims{Subject: "cus_1001", Kind: User, Scopes: []string{"orders:cancel"}}, ReadOrder, order, false, "missing scope"},
{"service acts for owner", onBehalf, ReadOrder, order, true, "owner"},
{"service without actor", batch, ReadOrder, order, false, "actor"},
{"service acts for another customer", Claims{Subject: "order-worker", Kind: Service, Actor: "cus_2002", Scopes: []string{"orders:read"}}, ReadOrder, order, false, "actor"},
{"service cancel needs cancel scope", onBehalf, CancelOrder, order, false, "missing scope"},
{"unknown action", owner, Action("order:refund"), order, false, "unknown action"},
{"non-order resource", owner, ReadOrder, Resource{Type: "invoice", Customer: "cus_1001"}, false, "unknown resource"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := Authorize(tc.claims, tc.action, tc.res)
if got.Allow != tc.allow {
t.Fatalf("allow = %v, want %v (%s)", got.Allow, tc.allow, got.Reason)
}
if !strings.Contains(got.Reason, tc.reason) {
t.Fatalf("reason = %q, want it to mention %q", got.Reason, tc.reason)
}
})
}
}
实测输出
把上述文件放进模块 archlab(go 1.26.0),执行 gofmt -l .、go vet ./...、go test ./internal/access/ -v,格式化与静态检查均无输出,测试结果如下:
=== RUN TestTokenRoundTripAndFailures
--- PASS: TestTokenRoundTripAndFailures (0.00s)
=== RUN TestAuthorizeOwnershipAndScopes
=== RUN TestAuthorizeOwnershipAndScopes/owner_reads_own_order
=== RUN TestAuthorizeOwnershipAndScopes/customer_reads_someone_else_order
=== RUN TestAuthorizeOwnershipAndScopes/owner_without_read_scope
=== RUN TestAuthorizeOwnershipAndScopes/service_acts_for_owner
=== RUN TestAuthorizeOwnershipAndScopes/service_without_actor
=== RUN TestAuthorizeOwnershipAndScopes/service_acts_for_another_customer
=== RUN TestAuthorizeOwnershipAndScopes/service_cancel_needs_cancel_scope
=== RUN TestAuthorizeOwnershipAndScopes/unknown_action
=== RUN TestAuthorizeOwnershipAndScopes/non-order_resource
--- PASS: TestAuthorizeOwnershipAndScopes (0.00s)
PASS
ok archlab/internal/access 0.240s
九个策略子用例与六个令牌检查全部通过。注意测试没有启动 HTTP 服务器:策略是纯函数,越权判断不依赖路由、框架或数据库。这也是把授权从中间件里拆出来的直接收益——最容易出错的归属逻辑可以廉价地穷举。
内部调用的身份传递
服务间传递身份有三条规则。第一,令牌由调用方所在的工作负载签发或由统一身份服务签发,接收方必须独立验签,不能因为“来自订单服务”就跳过。第二,Actor 必须来自已验证的用户令牌,不能从 X-Customer-Id 这类请求头直接复制;否则任何服务都能替任意客户操作。第三,令牌只向下游传递当前调用需要的最小 scope,不要把一个用户的完整权限原样转发给只需要读库存的服务。
一个常见的错误是“网关已经验过了”。网关验签只能证明令牌在进入集群时有效,不能证明调用链路上没有被转发、重放或降权。每个执行敏感动作的服务都要自己验,验完再授权。这听起来重复,但重复的是几微秒的 HMAC,省下的是横向移动的整条路径。
令牌有效期按调用链长度设置。arch-lab 用 5 分钟,足够覆盖一次同步调用;如果链路里有异步任务,不要延长令牌,而是让任务携带明确的任务身份和资源 ID,由执行方重新走一次授权。长期令牌是服务间身份最常见的退化形式。
最小权限的三个决定
第一个决定是不设万能 scope。订单服务的普通接口只认 orders:read 和 orders:cancel,管理操作走独立端点和独立身份。第二个决定是服务身份必须带 Actor,没有“服务自己的资源”这种说法。第三个决定是资源归属以数据库为准,路径参数和请求体只用于定位,不用于授权。
这三个决定都有代价。独立管理端点意味着多一套路由和审计;强制 Actor 意味着后台批处理要模拟用户或走专门的任务身份;以数据库为准意味着每次授权前要多一次查询。这些代价换来的是可解释的拒绝原因和可穷举的测试面。相比之下,“先上线再补权限”的代价通常在一次越权事件之后才被看见。
常见错误与边界
第一,把认证当授权。令牌有效只说明主体真实,不说明它拥有这张订单。第二,把内网当身份。内部令牌人人可用,等于没有身份。第三,服务令牌不带 Actor,或者 Actor 从请求头读取,前者无法审计,后者可以伪造。
第四,先返回 403 再查订单。攻击者可以据此枚举订单是否存在。第五,scope 设计成通配符。orders:* 一旦出现,最小权限就只剩口号。第六,令牌长期有效且无法吊销。短期令牌加 jti 黑名单是更低成本的起点。
边界也要说清楚:HMAC 共享密钥只适用于同一个信任域内的演示,跨域服务应使用非对称签名或平台工作负载身份(如 SPIFFE/mTLS);本节的策略不处理字段级权限(同一订单里隐藏收货地址),那需要响应投影而不是布尔授权;也不处理授权缓存,缓存会引入“撤权后仍可访问”的窗口,必须单独设计失效策略。安全架构的整体分层可以回看 安全架构设计 。
小结
认证产出主体声明,授权消费声明与资源,两者分开才能分别测试。arch-lab 用 User/Service 两种主体、sub/act 两个字段把“谁在调用”和“代表谁”拆开;策略函数 Authorize 同时检查 scope 与订单归属,服务没有绕过归属的默认分支。令牌测试覆盖篡改、错钥、畸形与过期,策略测试用九个用例覆盖跨客户读取、缺 scope、无 actor 与未知动作。gofmt、go vet 与 go test 全部通过,越权判断可以在不启动服务器的前提下被穷举。
阅读导航:上一节:9.3 架构治理的最小流程 · 下一节:10.2 输入边界与数据分级 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。