一个“多给点权限省得再申请”的应用
某开放平台接入第三方 CRM,申请单上列着订单、用户、发票、营销、物流五组 scope。开发者说“先都勾上,省得以后反复申请”,审批同学看到是合作方,直接通过。上线后这个应用实际只调用了订单读取和用户基础资料两个接口,其余 scope 一次都没用过。
三个月后,应用把测试密钥写进了前端仓库,攻击者拿到密钥后批量导出订单和用户手机号。受影响的不只是一个商家,而是所有落在这些 scope 覆盖范围内的数据。复盘时最刺眼的一句话是:这个应用从来没有调用过发票、营销、物流接口,但这些权限一直开着。
开放平台的权限问题通常不是“校验写错了”,而是“根本没想清楚谁在调用、以谁的名义调用、调用到什么程度”。这三件事对应应用身份、用户身份和 scope,混在一起就会放大一次泄露的影响面。
应用身份与用户身份是两条独立的校验
应用身份回答“哪个第三方在调用平台”,凭证是应用 ID 和 secret;用户身份回答“这次调用以哪个用户的名义”,凭证是用户授权后拿到的 access token。两者缺一不可:只有应用身份,平台不知道数据属于谁;只有用户身份,平台不知道是哪个应用在代表用户操作。
| 凭证 | 回答的问题 | 泄露后的影响 | 建议生命周期 |
|---|---|---|---|
| 应用 ID + secret | 谁在调用平台 | 攻击者可冒充应用,消耗配额并读取已授权数据 | 与开发者主体绑定,支持轮换 |
| 用户 access_token | 以谁的名义调用 | 攻击者可读取该用户的数据 | 随用户授权,可单独撤销 |
| scope | 应用被允许做什么 | 越权读写,影响面从一个用户扩大到全租户 | 审核后授予,变更走审批 |
| 配额 | 一天能调用多少次 | 恶意刷取拖垮平台、推高账单 | 按应用与接口分别配置 |
一个常见错误是把应用 secret 当成用户令牌用,或者在网关只校验了 secret 就放行所有接口。secret 泄露的影响是应用级的,用户令牌泄露的影响是用户级的;把两者合并成一把钥匙,等于让一次泄露同时具备两种影响。
最小权限:scope 要有语义、粒度和默认拒绝
scope 的命名建议固定为 resource:action,例如 order:read、order:write、user:profile:read。名字要能让审批人一眼判断影响面,不要用 all、full、v2 这种无法审计的词。匹配必须精确且大小写敏感,不做前缀匹配,也不做通配符匹配,否则 order:read 会意外匹配到 order:read:all。
scope 列表在加载时要去掉首尾空白、丢弃空值、去重并排序。去重不是为了好看,而是为了让“配置里写了两遍”和“配置里写了一遍”产生完全相同的授权结果,避免重复项在审计报表里制造噪声。默认行为必须是拒绝:没有显式授予的 scope 一律报错,而不是“没配置就放行”。
失败场景:全量 scope 加一把共享密钥
回到开头。这个应用同时具备三个条件:scope 覆盖全部业务、secret 放在客户端、配额没有单独配置。攻击者拿到 secret 后不需要理解业务,只要按接口文档逐个调用,就能在几个小时内导出全量订单。平台侧看到的是“一个正常应用在正常调用”,因为签名、scope、IP 白名单全部通过。
更麻烦的是账单。假设这个应用每天有 20 万次正常调用,攻击者追加了 300 万次导出请求,按每次 0.002 元计算,一天多出 6000 元成本;如果接口背后还有短信、地图、物流查询等按次计费的下游,损失会继续放大。配额缺失让“能调用”直接等于“可以无限调用”。
第三个变种是用户令牌被应用长期保存。应用为了“提升体验”,把用户的 refresh token 存进自己的数据库,用户撤销授权后仍然能用旧令牌调用。平台必须支持按用户撤销,并且撤销要立刻生效,不能等令牌自然过期。
配额要按应用与接口两个维度
只按应用配一个总配额不够。一个应用可能正常调用 10 万次订单查询,却在某个营销接口上被刷 50 万次;总配额看不出异常,单接口配额才能挡住。反过来,只按接口配额也不够,因为攻击者可以分散到多个接口,绕过单接口上限。
实践里至少有两层:应用级日配额负责账单和总量,接口级速率限制负责瞬时压力和热点保护。应用级配额为 0 表示不限量,这个值只应该给内部应用或经过单独审批的合作方;外部应用必须填写正数。已用次数为负数说明计数器或统计链路有 bug,应该直接报错,而不是当成 0 继续放行。
配额检查也有顺序:先校验应用身份,再校验 scope,最后看配额。身份不合法时不需要查配额,scope 不足时也不应该消耗配额。这个顺序能减少无效查询,也能让错误码更准确地告诉调用方该修什么。
用 App、Authorize 与 CheckQuota 把住入口
下面的实现把三件事拆开:Normalize 负责规整应用定义,Authorize 只做 scope 精确匹配,CheckQuota 只看用量和上限。三个函数都不依赖存储,调用方可以从配置中心、数据库或缓存里取到 App 后再调用。
// file: internal/openplatform/app.go
// tier: L1
// verify: go test ./internal/openplatform/
package openplatform
import (
"errors"
"fmt"
"sort"
"strings"
)
var (
// ErrEmptyAppID 表示请求没有携带应用身份。
ErrEmptyAppID = errors.New("openplatform: empty app id")
// ErrEmptyScope 表示请求没有指明需要的权限。
ErrEmptyScope = errors.New("openplatform: empty scope")
// ErrScopeDenied 表示应用没有获得该 scope。
ErrScopeDenied = errors.New("openplatform: scope not granted")
// ErrNegativeQuota 表示每日配额被配置成负数。
ErrNegativeQuota = errors.New("openplatform: negative daily quota")
// ErrQuotaExceeded 表示应用当天的调用额度已经用尽。
ErrQuotaExceeded = errors.New("openplatform: daily quota exceeded")
// ErrInvalidUsage 表示已用次数为负数,调用方统计有误。
ErrInvalidUsage = errors.New("openplatform: invalid usage")
)
// App 是第三方应用在开放平台上的身份。App.ID 回答“谁在调用”,
// 用户令牌回答“以谁的名义调用”,两者必须分开校验,不能互相替代。
type App struct {
ID string
Scopes []string
DailyQuota int
}
// Normalize 返回应用定义的一份规整副本:scope 去掉首尾空白、丢掉空值、
// 去重并排序。DailyQuota 为 0 表示不限量,负数视为配置错误。
func Normalize(a App) (App, error) {
if strings.TrimSpace(a.ID) == "" {
return App{}, ErrEmptyAppID
}
if a.DailyQuota < 0 {
return App{}, fmt.Errorf("%w: %d", ErrNegativeQuota, a.DailyQuota)
}
seen := make(map[string]struct{}, len(a.Scopes))
scopes := make([]string, 0, len(a.Scopes))
for _, s := range a.Scopes {
s = strings.TrimSpace(s)
if s == "" {
continue
}
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
scopes = append(scopes, s)
}
sort.Strings(scopes)
a.Scopes = scopes
return a, nil
}
// Authorize 检查应用是否被授予了某个 scope。匹配是精确且大小写敏感的,
// 不做前缀、通配或包含匹配;空 scope 直接拒绝。Scopes 会在校验前去重,
// 因此重复配置不会放大权限,也不会改变匹配结果。
func Authorize(a App, scope string) error {
if strings.TrimSpace(scope) == "" {
return ErrEmptyScope
}
normalized, err := Normalize(a)
if err != nil {
return err
}
for _, granted := range normalized.Scopes {
if granted == scope {
return nil
}
}
return fmt.Errorf("%w: app=%s scope=%s", ErrScopeDenied, a.ID, scope)
}
// CheckQuota 判断应用在已经调用 used 次之后还能不能继续调用。
// DailyQuota 是当天允许的调用上限,0 表示不限量;used 为已发生的调用数。
// used 达到上限时拒绝,因为这一次调用会让总数超过配额。
func CheckQuota(a App, used int) error {
normalized, err := Normalize(a)
if err != nil {
return err
}
if used < 0 {
return fmt.Errorf("%w: %d", ErrInvalidUsage, used)
}
if normalized.DailyQuota == 0 {
return nil
}
if used >= normalized.DailyQuota {
return fmt.Errorf("%w: app=%s used=%d quota=%d", ErrQuotaExceeded, a.ID, used, normalized.DailyQuota)
}
return nil
}
实现里有几个关键点:
Normalize返回副本,不修改调用方传入的App。配置加载和请求校验可以共享同一份数据,不会因为一次校验把原始配置改写掉。- 应用 ID 用
strings.TrimSpace判空,只有空格组成的 ID 会被拒绝。ID 本身不做裁剪,保留原值便于审计时和开发者后台对齐。 - scope 去空、去重、排序。重复配置不会放大权限,排序让审计报表和测试断言稳定。
Authorize先拒绝空 scope,再校验应用定义,最后逐个精确比较。Order:Read、order:read、order都不会匹配order:read。CheckQuota在used < 0时直接报错,说明统计链路有问题;DailyQuota == 0表示不限量,只对内部应用开放。- 已用次数达到上限就拒绝,因为这一次调用会让总数超过配额。边界值
used == quota返回ErrQuotaExceeded,测试会固定这个语义。
这套代码不负责存储、轮换和审计,那些属于开放平台的控制面。服务端只保证“拿到一个应用定义后,授权和配额判断是确定的、可测试的”。
测试:去重、精确匹配与配额边界
测试重点不是覆盖率,而是把几个容易退化的语义钉死:输入不能被修改、匹配必须精确、配额边界不能漂移。
// file: internal/openplatform/app_test.go
// tier: L1
// verify: go test ./internal/openplatform/
package openplatform
import (
"errors"
"reflect"
"testing"
)
func TestNormalizeDedupesAndSorts(t *testing.T) {
got, err := Normalize(App{
ID: "app-1",
Scopes: []string{"order:read", " order:write ", "order:read", "", " "},
DailyQuota: 100,
})
if err != nil {
t.Fatalf("Normalize = %v, want nil", err)
}
if got.ID != "app-1" {
t.Fatalf("ID = %q, want app-1", got.ID)
}
want := []string{"order:read", "order:write"}
if !reflect.DeepEqual(got.Scopes, want) {
t.Fatalf("Scopes = %v, want %v", got.Scopes, want)
}
if got.DailyQuota != 100 {
t.Fatalf("DailyQuota = %d, want 100", got.DailyQuota)
}
}
func TestNormalizeDoesNotMutateInput(t *testing.T) {
scopes := []string{"b", "a", "b"}
_, err := Normalize(App{ID: "app-1", Scopes: scopes})
if err != nil {
t.Fatalf("Normalize = %v, want nil", err)
}
if want := []string{"b", "a", "b"}; !reflect.DeepEqual(scopes, want) {
t.Fatalf("input scopes mutated to %v, want %v", scopes, want)
}
}
func TestNormalizeRejectsInvalidApp(t *testing.T) {
if _, err := Normalize(App{ID: " "}); !errors.Is(err, ErrEmptyAppID) {
t.Fatalf("blank id = %v, want ErrEmptyAppID", err)
}
if _, err := Normalize(App{ID: "app-1", DailyQuota: -1}); !errors.Is(err, ErrNegativeQuota) {
t.Fatalf("negative quota = %v, want ErrNegativeQuota", err)
}
}
func TestAuthorizeExactMatch(t *testing.T) {
app := App{ID: "app-1", Scopes: []string{"order:read", "order:write", "order:read"}}
for _, scope := range []string{"order:read", "order:write"} {
if err := Authorize(app, scope); err != nil {
t.Fatalf("Authorize(%q) = %v, want nil", scope, err)
}
}
for _, scope := range []string{"order:delete", "Order:Read", "order", " order:read"} {
if err := Authorize(app, scope); !errors.Is(err, ErrScopeDenied) {
t.Fatalf("Authorize(%q) = %v, want ErrScopeDenied", scope, err)
}
}
for _, scope := range []string{"", " "} {
if err := Authorize(app, scope); !errors.Is(err, ErrEmptyScope) {
t.Fatalf("Authorize(%q) = %v, want ErrEmptyScope", scope, err)
}
}
}
func TestAuthorizeRejectsInvalidApp(t *testing.T) {
if err := Authorize(App{}, "order:read"); !errors.Is(err, ErrEmptyAppID) {
t.Fatalf("empty app = %v, want ErrEmptyAppID", err)
}
bad := App{ID: "app-1", DailyQuota: -5}
if err := Authorize(bad, "order:read"); !errors.Is(err, ErrNegativeQuota) {
t.Fatalf("negative quota = %v, want ErrNegativeQuota", err)
}
}
func TestCheckQuota(t *testing.T) {
app := App{ID: "app-1", DailyQuota: 3}
for _, used := range []int{0, 1, 2} {
if err := CheckQuota(app, used); err != nil {
t.Fatalf("used=%d = %v, want nil", used, err)
}
}
for _, used := range []int{3, 4, 1000} {
if err := CheckQuota(app, used); !errors.Is(err, ErrQuotaExceeded) {
t.Fatalf("used=%d = %v, want ErrQuotaExceeded", used, err)
}
}
if err := CheckQuota(App{ID: "app-2", DailyQuota: 0}, 1000000); err != nil {
t.Fatalf("unlimited quota = %v, want nil", err)
}
if err := CheckQuota(app, -1); !errors.Is(err, ErrInvalidUsage) {
t.Fatalf("used=-1 = %v, want ErrInvalidUsage", err)
}
if err := CheckQuota(App{}, 0); !errors.Is(err, ErrEmptyAppID) {
t.Fatalf("empty app = %v, want ErrEmptyAppID", err)
}
}
TestNormalizeDedupesAndSorts 验证空白 scope 被丢弃、重复项只保留一次、结果有序,同时确认 ID 和配额原样保留。TestNormalizeDoesNotMutateInput 在调用前后比较原切片,防止以后有人为了省一次分配直接原地排序。
TestAuthorizeExactMatch 覆盖大小写、前缀、带空格三种“看起来像但不是”的输入,它们都必须返回 ErrScopeDenied;空 scope 则返回 ErrEmptyScope。TestAuthorizeRejectsInvalidApp 确认空 ID 和负配额在校验 scope 之前就被挡住。
TestCheckQuota 对配额 3 逐一检查 0、1、2 放行,3、4、1000 拒绝;配额 0 时一百万次调用仍然放行;used=-1 返回 ErrInvalidUsage。这些用例把“达到上限即拒绝”和“0 表示不限量”两个约定固定下来。
运行命令与真实输出
在 lab 目录执行:
cd .local/checks/webapp-lab && go test ./internal/openplatform/ -run 'TestNormalize|TestAuthorize|TestCheckQuota' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestNormalizeDedupesAndSorts
--- PASS: TestNormalizeDedupesAndSorts (0.00s)
=== RUN TestNormalizeDoesNotMutateInput
--- PASS: TestNormalizeDoesNotMutateInput (0.00s)
=== RUN TestNormalizeRejectsInvalidApp
--- PASS: TestNormalizeRejectsInvalidApp (0.00s)
=== RUN TestAuthorizeExactMatch
--- PASS: TestAuthorizeExactMatch (0.00s)
=== RUN TestAuthorizeRejectsInvalidApp
--- PASS: TestAuthorizeRejectsInvalidApp (0.00s)
=== RUN TestCheckQuota
--- PASS: TestCheckQuota (0.00s)
PASS
ok webapp/internal/openplatform 0.239s
小结
应用身份和用户身份必须分开校验,scope 要最小化、精确匹配、默认拒绝,配额要同时按应用和接口两个维度配置。Normalize、Authorize、CheckQuota 把规整、授权和配额判断拆成三个纯函数,边界由测试固定。下一节转向回调:即使调用方身份正确,一条被重放的请求仍然可能造成重复发货。
阅读导航:上一节:目录与学习路径 · 下一节:10.2 回调、签名与重放 。
常见错误与边界
- 把应用 secret 当成用户令牌。secret 泄露是应用级影响,用户令牌泄露是用户级影响,两者不能合并成一把钥匙。
- scope 用通配符或前缀匹配。
order:read意外匹配order:read:all,权限边界会比设计时更宽。 - 审批时按“合作方”放行全量 scope。合作方也会被拖库,最小权限要按实际调用记录收敛。
- 默认放行未配置的 scope。没有显式授予就应该拒绝,不能把“没写”解释成“都可以”。
- 只配应用级总配额。攻击者集中刷一个接口时,总配额看不出异常,必须再加接口级限流。
- 配额为 0 直接理解成“不能用”。本实现约定 0 表示不限量,内部应用和外部应用要用不同默认值。
- 已用次数为负数时当成 0。统计链路已经出错,继续放行会让配额彻底失效。
- 授权、配额、审计混在一个函数里。纯函数拆开后才能单测边界,也才能在网关和业务层复用。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。