Access Token 权限粒度改造设计(Ech0)
April 9, 2026 · View on GitHub
日期:2026-03-23
状态:已落地(实现以 internal/model/auth、internal/util/jwt、internal/middleware 为准;本文档保留设计背景与决策记录)
范围:后端鉴权与令牌管理(不含完整 OAuth2 授权服务器)
1. 背景与目标
当前 Ech0 的 access token 与登录 JWT 基本等价,核心 claims 仅包含 user_id 和 username。这使得 token 在服务端的授权判断中会继承账号角色能力(尤其是 admin),在“不受信任客户端”场景下风险偏高。
本次改造目标:
- 将 access token 从“身份票据”改为“能力票据(capability token)”。
- 引入固定白名单 scope,实现最小权限授权(least privilege)。
- 保持 Ech0 轻量定位,不引入完整 OAuth2 Server 复杂度。
- 旧 JWT 不兼容(含旧 access token 与旧 session token),直接失效并重新签发。
非目标:
- 不在本阶段实现第三方 OAuth2 授权码流程(Auth Code + PKCE)。
- 不重做现有 session 登录体系。
2. 设计原则
- 默认拒绝:未声明 scope 的操作默认不允许。
- 固定词表:scope 使用白名单枚举,不允许自由字符串。
- 高危隔离:管理能力与内容能力严格分离(
admin:*与非admin:*)。 - 最小侵入:复用现有
viewer与路由分组结构,避免大规模重构。 - 可观测可审计:围绕
jti记录授权结果与拒绝原因。
补充边界定义:
- 本文“默认拒绝”仅针对
typ=access的 API token 生效。 typ=session(站内登录态)在本阶段保持现有授权模型;升级后旧 session token 需重新登录获取新 token。
3. 权限模型
3.1 Token 类型
统一通过 claims 字段 typ 区分:
session:站内登录态 token(延续现状)access:用于 API 调用的能力型 token(本次改造重点)
授权规则(唯一事实来源):
- 当
typ=session时:不进入RequireScopes,继续使用现有业务授权逻辑(如user.IsAdmin)。 - 当
typ=access时:必须经过RequireScopes;缺少 scope 一律403。 - 当 token 缺失
typ或typ不在允许集合(session、access)时:直接401。
上线前置要求:
- 新签发登录 token 必须写入
typ=session; - 新签发 API token 必须写入
typ=access; - 旧 JWT(含旧 access token 与旧 session token)在切换后全部失效,不提供兼容窗口。
3.2 Scope 词表(第一批)
建议首批控制在 8-12 个以内:
echo:readecho:writecomment:readcomment:writecomment:moderatefile:readfile:writeprofile:readprofile:writeadmin:settingsadmin:useradmin:token
约束:
- 面向不受信任客户端时,UI 默认不展示
admin:*。 - 服务端始终以 scope 判定是否允许调用,即便用户角色是 admin。
3.3 Audience 与时效
aud:声明 token 使用场景(如public-client、cli、integration)。exp:默认短时效(建议 24h);长期 token 需显式选择。jti:每个 token 唯一 ID,用于审计、吊销和定位风险。
claim 来源统一策略:
aud与jti仅使用jwt.RegisteredClaims中的Audience与ID。- 禁止在自定义 claims 中重复定义同名字段,避免解析歧义与双写冲突。
4. Claims 与数据结构改造
4.1 JWT Claims(internal/model/auth)
在现有 MyClaims 上新增:
Type string json:"typ"Scopes []string json:"scope"
说明:
aud与jti使用RegisteredClaims.Audience和RegisteredClaims.ID,不新增重复字段。scope使用数组而非空格字符串,减少解析歧义。
4.2 访问令牌模型(internal/model/setting.AccessTokenSetting)
建议新增字段:
TokenType string(默认access)Scopes string(JSON 序列化存储)Audience string(单值或 JSON,首版可单值)JTI string(唯一索引)LastUsedAt *time.Time
4.3 创建 DTO(AccessTokenSettingDto)
新增请求字段:
Scopes []stringAudience string
并在服务层校验:
- scope 全部在白名单内;
admin:*scope 仅高权限用户可签发;- audience 必须在允许列表中。
5. 鉴权流程与路由边界
5.1 中间件拆分
建议在现有 JWTAuthMiddleware 基础上拆分职责:
-
ParseTokenMiddleware- 负责 token 提取、验签、过期检查、解析
typ/scope/aud/jti; - 将 token 元信息注入
viewer扩展上下文。
- 负责 token 提取、验签、过期检查、解析
-
RequireScopes(scopes ...string)- 仅负责授权判定;
- 缺失权限统一返回
403(NO_PERMISSION_DENIED语义可沿用并补充错误码)。
传输约束(针对不受信任客户端):
- 高权限 scope(任意
admin:*)禁止通过 query 参数传 token,仅允许Authorization: Bearer。 - query token 仅用于受限场景(如媒体直链),且仅接受低风险读权限(如
file:read)。 - 违反传输约束时返回
403,并记录审计原因token_transport_forbidden。
5.2 路由应用方式(internal/router/*)
- 保留
PublicRouterGroup与AuthRouterGroup。 - 在
AuthRouterGroup内按资源挂RequireScopes:- 写接口:例如
POST /echo需要echo:write - 管理接口:例如设置、用户管理、token 管理需要相应
admin:*
- 写接口:例如
- 匿名放行接口维持现有策略(首页分页、今日内容、公开详情等)。
5.3 Service 层策略
- 业务角色(
user.IsAdmin)继续保留,用于业务规则本身。 - API 调用授权以 scope 为先:token scope 不足时必须拒绝,即使用户是 admin。
viewer 扩展约定:
- 在
pkg/viewer中扩展 token 元信息读取能力(至少包含typ、scopes、jti、audience)。 - service 层从
viewer读取授权上下文,不直接反复解析 JWT。
6. 错误语义与可观测性
401 Unauthorized:token 缺失/格式错误/签名错误/过期。403 Forbidden:token 有效但 scope 或 audience 不满足。
审计日志(脱敏)建议记录:
jtiuser_idroute + methoddecision(allow/deny)reason(scope_missing、aud_mismatch、expired 等)
错误码建议(与 HTTP 状态组合):
401:复用现有 token 缺失/无效/解析失败错误码。403:新增或明确区分以下错误码:ErrCodeScopeForbiddenErrCodeAudienceForbiddenErrCodeTokenTransportForbidden
7. 切换方案(旧 token 不兼容)
一次性切换:
-
结构升级
- 为
access_token_settings增列:token_type、scopes、audience、jti、last_used_at; - 给
jti建唯一索引。
- 为
-
强制失效
- 发布切换版本时,服务端拒绝所有“无
typ或typ非法”的旧 JWT(含旧 access 与旧 session); - 管理端仅支持创建新格式 token(必须带 scopes + audience +
typ=access)。
- 发布切换版本时,服务端拒绝所有“无
-
重新签发
- 管理后台提示“旧 token 已失效,请重新创建”;
- 由管理员按最小权限重新生成并分发 token。
-
回滚策略
- 回滚到旧版本可恢复旧鉴权行为;
- 新版本内不保留 legacy 分支,避免长期双轨维护。
8. 测试策略
8.1 单元测试
internal/util/jwt:覆盖typ/scope/aud/jti/exp的签发与解析。internal/middleware(auth.go):覆盖 401/403 分支。internal/service/setting/access_token_service:覆盖 scope 白名单与高危 scope 拒绝逻辑。
8.2 集成测试
建立最小权限矩阵:
- 只读 token:可读不可写;
- 内容写 token:不可访问管理接口;
- 管理 token:仅可访问授权的
admin:*路径; - audience 不匹配:统一拒绝。
落地方式:
- 基于
httptest+gin.Engine,复用现有internal/router/router_test.go风格。 - 测试 token 由测试辅助方法统一签发,避免手写 JWT 字符串。
- 至少覆盖
echo、setting、file三类代表路径。
8.3 回归测试
- 对新签发的
typ=sessiontoken,登录/鉴权代码路径与升级前一致;不含合法typ的旧 session token 应返回401并引导重新登录; - 匿名公开接口行为不变;
- 现有基础功能(发布、评论、文件、设置)在授权正确时行为不变。
- 旧 access token 在切换后稳定返回
401,且新 token 行为符合 scope 约束。
9. 交付拆分建议
- 里程碑 A:Claims + 数据模型 + 新建 token API 入参校验
- 里程碑 B:中间件扩展 + 路由挂载
RequireScopes - 里程碑 C:旧 token 强制失效 + 管理端重建提示
- 里程碑 D:测试补齐 + 文档更新(README / API 文档)
10. 风险与应对
-
风险:scope 词表过粗导致“看似细粒度,实则过权”
应对:首版小词表 + 真实集成反馈后迭代。 -
风险:切换当日第三方集成全部失效
应对:发布公告 + 版本说明 + 管理后台显式提示“先创建新 token 再升级”。 -
风险:授权逻辑分散导致漏检
应对:统一使用RequireScopes,减少 service 内手写鉴权分支。 -
风险:query token 在不受信任环境泄露
应对:限制 query token 仅可用于低风险读场景,并禁用高危 scope 的 query 传递。
11. 结论
对 Ech0 当前“个人优先、轻量自托管、支持集成”的定位,推荐采用:
- 固定白名单 scope + audience + 短时效 access token;
- 与 session token 分型管理;
- 通过中间件统一授权与错误语义;
- 采用一次性切换,旧 JWT(含旧 access 与旧 session)直接失效并重建。
该方案在安全收益、实现复杂度和维护成本之间达到平衡,适合作为当前版本的权限粒度升级路径。