Scope 与权限
这一课你将能做到
- 解释 scope 是「用户可见、可同意也可拒绝」的授权粒度,并说明 token 里实际批准的 scope 可能少于申请值
- 为自己的 API 设计一套 scope 命名(如
tracks:read / tracks:write),与 OIDC scope 分开、可文档化
- 在 Spring Resource Server 用
SCOPE_ 前缀的 @PreAuthorize 做细粒度控制,说清 401 与 403 在 scope 场景下的差别
为什么要学
同意页上「此应用将能够:读取你的公开资料、获取你的邮箱地址」——每个复选框背后就是一个 scope。scope 是用户与权限之间唯一的产品化接口:申请多了,用户不敢点同意,合规风险也大;申请少了,功能中途再弹一次授权,体验割裂。
而 scope 之所以不只是「展示文案」,是因为 Resource Server 必须把它变成可执行的访问控制。API 只验「有没有登录」、不看 scope,等于把最小权限原则废掉——一个只被授权读课表的第三方应用,就能顺手调你的写接口。
后端与全栈都绕不开它:对接第三方 IdP 时 scope 决定你能拿到什么资料;自建开放 API 时 scope 决定第三方能做什么。面试里「scope 和 role 的区别」「401 与 403 什么时候用哪个」是权限设计的基础题。
先建立图像
Client 请求:scope=openid profile tracks:read tracks:write
│
▼
用户在同意页看到两个复选框:「读取公开资料」「创建/管理课表」
│
▼
token 响应:scope=openid profile tracks:read
(tracks:write 被用户拒绝或 IdP 未批准)
│
▼
Resource Server 逐条比对:
GET /api/v1/tracks 需要 SCOPE_tracks:read → 有 → 200
POST /api/v1/tracks 需要 SCOPE_tracks:write → 无 → 403
三类 scope 不要混为一谈:
| 类型 | 例子 | 谁消费 |
|---|
| OIDC 标准范围 | openid profile email | Client 认人 / 取资料 |
| IdP 预置范围 | GitHub 的 read:user、Google 的 drive.file | 调 IdP 自己的 API |
| 自建 API 范围 | tracks:read tracks:write | 你自己的 Resource Server |
跟着做
步骤 1:为你的功能设计 scope 字符串
在笔记里写出(本站示例):
只读课表列表/详情 → tracks:read
创建/更新/删除课表 → tracks:write
OIDC 登录取资料 → openid profile email
命名规则:短、稳定、可文档化;用 资源:动作 的冒号风格;避免一次给 admin:* 这种通配。scope 一旦发出给第三方用,改名就是破坏性变更,起名要像设计数据库字段一样慎重。
步骤 2:观察 authorize 与 token 响应里的 scope
# authorize 请求(申请)
GET /authorize?...&scope=openid%20profile%20tracks:read
# token 响应(实际批准)
{
"access_token": "eyJ...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "openid profile tracks:read"
}
写下一句结论:业务代码必须以响应里的 scope 为准,不能假设「我请求了就一定有」。用户可以拒绝、IdP 可以裁剪、管理员可以配置更严的策略。
步骤 3:Resource Server 按 scope 拦截
// 中文注释:Spring 默认把 JWT 里的 scope 映射成 SCOPE_ 前缀的 GrantedAuthority
@Configuration
@EnableMethodSecurity // 使 @PreAuthorize 生效
public class MethodSecurityConfig { }
@RestController
@RequestMapping("/api/v1/tracks")
public class TrackApiController {
@GetMapping
@PreAuthorize("hasAuthority('SCOPE_tracks:read')")
public List<Track> list() { /* ... */ }
@PostMapping
@PreAuthorize("hasAuthority('SCOPE_tracks:write')")
public Track create(@RequestBody Track body) { /* ... */ }
}
步骤 4:用 curl 对照三种结果
# ① 不带 token → 401(未认证)
curl -i http://localhost:8080/api/v1/tracks
# ② 带只含 tracks:read 的 token 调写接口 → 403(已认证,权限不足)
curl -i -X POST http://localhost:8080/api/v1/tracks \
-H "Authorization: Bearer READ_ONLY_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"slug":"demo"}'
# ③ 带含 tracks:write 的 token → 200(业务成功)
curl -i -X POST http://localhost:8080/api/v1/tracks \
-H "Authorization: Bearer WRITER_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"slug":"demo"}'
把「401 vs 403」的对照写进错法表——这是前端与后端最容易打架的地方。
步骤 5:scope(OAuth 层)与本站角色(RBAC 层)分层
/api/v1/**(开放 API) → OAuth access_token + SCOPE_xxx(对第三方应用的授权)
/admin/**(站内管理) → 本站自研 JWT + 角色 ADMIN(对站内用户的授权)
两层各管各的:联邦用户可能有 tracks:read 但没有 ADMIN 角色;
管理员登录也不应该自动获得对外 API 的 scope。
步骤 6:写给第三方的 scope 说明(文档片段)
tracks:read — 读取公开课表列表与详情
tracks:write — 创建/更新课表(需申请审核,默认不发放)
openid — 获取标准化 id_token,用于登录
讲清楚要点
- scope 是用户同意的边界,所以默认只申请当前功能真正需要的权限——最小权限原则在这里落地。
- 用户可以拒绝部分 scope,所以 Client 必须实现「功能降级」:没有 email 就展示默认头像昵称,而不是整个流程报错。
- OIDC scope 与业务 API scope 语义不同,命名空间要分开:
profile 管资料,tracks:read 管课表,别用一个 user 词打天下。
- 同意页展示不等于执行,Resource Server 必须再验一次:token 可以被裁剪、被降级,API 端的
@PreAuthorize 才是最后防线。
- Spring 默认把 scope 映射成
SCOPE_xxx:@PreAuthorize 字符串漏了前缀,规则永不命中且不报错——最隐蔽的坑之一。
- 敏感操作要单独 scope:删除、改密这类操作可拆成
tracks:delete 并叠加二次确认,避免「一个写 scope 一把梭」。
你可能会懵的地方
-
scope 和角色(ROLE_ADMIN)是不是一回事?
都能表达权限,但来源不同:scope 来自「用户对某个 Client 的授权」(OAuth 层);角色来自「你站内用户模型」(RBAC 层)。联邦用户可能有 scope 而没有站内角色;反之亦然。
-
token 里没有 scope 字段怎么办?
JWT 可能用 scope(空格分隔字符串)或 scp(数组);不透明 token 需要调 introspection 端点查询。先查你对接的 IdP 文档确认格式。
-
为什么我申请了 email,id_token 里却没有?
三种可能:用户拒绝了;该 claim 只在 UserInfo 端点返回;scope 忘了加 openid。逐一排查。
-
Client Credentials 模式也有 scope 吗?
有,但语义是「应用自身的权限」,没有终端用户参与、没有同意页,通常由管理员在 IdP 侧预配置——更要严控发放。
-
scope 请求了但响应里少了一项,该报错还是继续?
看功能关键度:核心 scope 缺失可以中止并提示用户重新授权;边缘 scope 缺失应降级继续。把判定逻辑写在 Client 侧,别指望 Resource Server 替你想。
错法对照
| 错法 | 问题 | 正解 |
|---|
| 一次申请所有 scope「以后方便」 | 用户不信任,合规风险大 | 按功能增量申请 |
| API 只验 JWT 签名不看 scope | 签名合法 ≠ 有权操作 | 验签 + 过期 + scope 三步都走 |
把 openid 当成业务写权限 | OIDC 范围与 API 范围混用 | 两套命名空间分开设计 |
@PreAuthorize("hasAuthority('tracks:read')") 漏 SCOPE_ | 规则永不命中且静默 | 使用默认转换器的 SCOPE_tracks:read |
| 缺 scope 时返回 401 | 前端误判「未登录」,死循环跳登录 | 已认证但权限不足 → 403 |
卡点急救
- 现象:前端收到 403 后疯狂跳登录页。前端把一切 401/403 都当「没登录」处理了。约定清楚:401 才清会话去登录;403 提示「权限不足/需要申请更高 scope」。后端也别把权限不足包装成 401。
- 现象:
@PreAuthorize 写了但完全不生效。两个排查点:配置类有没有 @EnableMethodSecurity;authority 字符串是不是漏了 SCOPE_ 前缀(Spring 默认转换器会把 scope 变成 SCOPE_xxx)。
- 现象:用户拒绝了部分 scope,应用某个页面直接崩。Client 侧没做降级。处理:以 token 响应里的 scope 为准渲染功能开关;对「核心功能必需的 scope」被拒的场景,给出解释页并引导重新授权。
面试常问
-
scope 和 RBAC 角色的区别?
scope 是 OAuth 层的授权粒度,表达「用户同意某应用能做什么」,随授权发放、可撤销、用户可见;role 是业务系统内部的权限模型,表达「这个本地账号是什么身份」。开放 API 用 scope 隔离第三方,站内管理用角色控制用户,两者可以共存但不能互相顶替。
-
401 和 403 分别什么时候返回?
401 = 未认证(没带凭证、凭证无效或过期),响应应带 WWW-Authenticate 头;403 = 已认证但权限不足(scope 不够、角色不对)。把 403 当 401 返回会引发前端「清会话 → 跳登录 → 登录完还是 403」的死循环。
-
如何设计一套可演进的 scope 命名?
资源:动作(tracks:read/tracks:write)或 资源.动作;固定小写、提前文档化、发放可审计;新增能力加新 scope 而不是改旧语义;给敏感动作拆独立 scope。核心是「scope 语义一旦对外发布就是契约」。
自测三问
- 为什么 token 响应中的 scope 可能比 authorize 请求的少?Client 应如何应对?
- OIDC 的
openid 与自建的 tracks:write 分别服务谁?
- 无 token 与「token 缺 scope」访问写接口,状态码分别应该是什么?
小结一句话
Scope = 用户可见、可拒绝、可撤销的授权粒度;申请要克制,API 端必须用 SCOPE_ 规则强制校验,401 与 403 各司其职。
延伸