《后端架构设计·架构基础与边界》10.1 认证、授权与服务间身份

把用户身份与服务身份分开建模:用 HMAC 自签令牌区分 subject 与 actor,把订单归属校验写成纯函数策略,并用九个越权用例覆盖跨客户读取、服务无 actor、缺 scope、未知动作与资源类型,附 go test 实测输出与内部调用传递规则。

认证与授权不是一件事

认证回答“你是谁”,授权回答“你能对这个资源做什么”。这两件事经常被塞进同一个中间件,结果是一个函数既解析令牌又判断归属,最后没人说得清某条拒绝到底来自签名失败、令牌过期,还是客户 ID 不匹配。拆分之后,认证只产出经过验证的主体声明,授权只看声明与资源,两边都可以单独测试。

更隐蔽的问题在服务之间。订单服务调用库存服务时,很多系统只带一个“内部令牌”,库存服务看到令牌有效就放行。这等于把“在内网”当成了身份:任何能发请求的进程都拥有全部权限。一旦某个服务被攻破,攻击者不需要提权,直接沿着内部调用横向移动。

本节在 arch-lab 里把两类主体显式建模:用户身份(User)和服务身份(Service)。服务身份不代表它自己拥有资源,它只能携带 Actor 代表某个用户行事。授权策略同时检查动作范围与资源归属,任何“服务默认可看全部订单”的分支都不存在。零信任的背景可以回看 零信任架构 ,这里只落到订单 API 的可执行策略。

两个主体,三种声明

arch-lab 的令牌只保留授权需要的事实:

字段用户令牌服务令牌用途
subcus_1001order-worker谁在调用
kinduserservice主体类型
act空cus_1001服务代表谁
scporders:readorders:read允许的动作
exp5 分钟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 输入边界与数据分级 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查