刷新令牌
本页说明客户端如何获得刷新令牌(refresh token)并用它续期访问令牌,以及 wicket v0.1.4 在轮换、过期等方面实际做了什么、没做什么。后者直接影响你的安全设计,请完整读完。
拿到刷新令牌的两个条件
- 客户端设置
AllowOfflineAccess = true; - 授权请求的
scope中包含offline_access。
web.AllowOfflineAccess = true
web.AllowedScopes = []string{"openid", "profile", "orders.read"} // 不需要把 offline_access 写进来offline_access 是内置作用域,不需要在 IdentityResource 或 ApiScope 中定义,发现文档的 scopes_supported 会自动列出它。实测:
| 情况 | 结果 |
|---|---|
AllowOfflineAccess = true,请求含 offline_access | 令牌响应含 refresh_token |
AllowOfflineAccess = true,请求不含 offline_access | refresh_token 为空字符串 |
AllowOfflineAccess = false,请求含 offline_access | 授权端点拒绝(invalid_scope,跳转到错误页) |
授权码与 CIBA 流程签发刷新令牌均已实测。客户端凭据流没有用户,不应为其开启 AllowOfflineAccess。
用刷新令牌换取新令牌
curl -s -u web-app:web-secret \
-d grant_type=refresh_token \
-d refresh_token=$REFRESH_TOKEN \
http://localhost:5000/connect/token实测响应(截断):
{
"access_token": "eyJhbGciOiJSUzI1NiIsImtp...",
"expires_in": 3600,
"id_token": "eyJhbGciOiJSUzI1NiIsImtp...",
"refresh_token": "2AD2B6B67EB04056098359C8...",
"scope": "openid orders.read offline_access",
"token_type": "Bearer"
}- 原始请求含
openid时,刷新也会返回新的id_token。 - 刷新请求中的
scope参数在 v0.1.4 中被忽略:实测带scope=openid刷新,返回的仍是原始的全部作用域。 - 无效或不存在的刷新令牌返回
invalid_grant。
Client 上的刷新令牌字段
storage/models/client.go 中的字段及 NewClient() 默认值:
| 字段 | NewClient() 默认值 | v0.1.4 中是否生效 |
|---|---|---|
AllowOfflineAccess | false | 生效 |
UpdateAccessTokenClaimsOnRefresh | false | 生效(见下文) |
RefreshTokenUsage | wicket.TokenUsageOneTimeOnly | 未生效 |
RefreshTokenExpiration | wicket.TokenExpirationAbsolute | 未生效 |
AbsoluteRefreshTokenLifetime | 2592000(30 天) | 未生效 |
SlidingRefreshTokenLifetime | 1296000(15 天) | 未生效 |
用 NewClient() 而不是字面量
TokenUsage、TokenExpiration 的零值分别是 TokenUsageOneTimeOnly 和 TokenExpirationSliding。用 models.Client{} 字面量创建客户端会得到与 NewClient() 不同的默认值(还会漏掉 Enabled = true 等),请始终从 NewClient() 开始。
UpdateAccessTokenClaimsOnRefresh:
false(默认):复用上次签发时保存的访问令牌内容,只更新签发时间与有效期后重新签名。用户资料变化不会反映到新令牌中。true:根据刷新令牌中保存的用户主体重新生成访问令牌声明。
v0.1.4 的实现程度(务必了解)
v0.1.4 尚未实现刷新令牌的过期与轮换策略,上表中标为“未生效”的字段请勿依赖。具体表现为:
- 不轮换:刷新后返回的仍是同一个刷新令牌,
RefreshTokenUsage = OneTimeOnly不会让它变成一次性的。 - 不过期:保存时不记录签发时间与有效期,校验时也不检查;
AbsoluteRefreshTokenLifetime、SlidingRefreshTokenLifetime不参与计算。 - 不关联会话:用户登出不会使刷新令牌失效。
刷新令牌相关的其余安全策略也尚在完善中,修复版本发布后本页会同步更新。
在修复版本发布前的建议
- 只给确有需要、且能保管好密钥的机密客户端开启
AllowOfflineAccess;各客户端的AllowedScopes按最小权限配置。 - 按下文方法给刷新令牌补上有效期。
- 需要吊销时,直接调用你的
RefreshTokenStore.RemoveRefreshToken(wicket 没有注册/connect/revocation端点,该路径的请求会落到宿主 handler)。
存储端口:版本化更新
自定义持久化时,通过 cfg.SetRefreshTokenStore(store) 注入 storage.RefreshTokenStore。wicket 依赖以下语义(完整约定见 存储端口):
| 方法 | 要求 |
|---|---|
StoreRefreshToken(ctx, handle, token) | 只插入。handle 已存在时返回 storage.ErrDuplicateHandle 且不改动原记录;存入的副本版本号为 1,并回写到 token.Version |
GetRefreshToken(ctx, handle) | 返回独立副本;不存在时返回 storage.ErrNotFound(令牌端点据此回 invalid_grant) |
UpdateRefreshToken(ctx, handle, token, expectedVersion) | 乐观并发:仅当存储中的版本等于 expectedVersion 时写入,新版本为 expectedVersion + 1 并回写;否则返回 storage.ErrVersionConflict 且不改动 |
RemoveRefreshToken(ctx, handle) | 删除 |
RemoveExpired(ctx, cutoff) | 删除“CreationTime + Lifetime 早于 cutoff”的记录,返回删除数量;只回收空间,不参与有效性判断 |
每次刷新都会调用 UpdateRefreshToken(实测版本号从 1 变为 2)。两个请求并发刷新同一个令牌时,后写入的一方会遇到 ErrVersionConflict,令牌端点把它转换为 invalid_grant。数据库实现通常是 UPDATE ... SET version = version + 1 WHERE handle = ? AND version = ?,再检查受影响行数。
补上有效期之前不要调度 RemoveExpired
由于 v0.1.4 不为刷新令牌记录签发时间与有效期,RemoveExpired(ctx, time.Now()) 会把这些记录全部视为已过期而删除全部刷新令牌。wicket 不自起后台 goroutine,清理任务由宿主调度——在补上有效期(下一节)之前,不要为刷新令牌安排这个清理任务。
补上有效期:包装存储
在任何 RefreshTokenStore 外面包一层,就能在写入时记录签发时间与有效期、在读取时拒绝过期令牌。下面的代码已在实验程序中编译运行:有效期设为 3 秒时,第一次刷新成功,4 秒后再刷新返回 invalid_grant,记录同时被删除。
// expiringRefreshTokens 为任意 RefreshTokenStore 补上“签发时间 + 绝对有效期”。
type expiringRefreshTokens struct {
storage.RefreshTokenStore
ttl time.Duration
now func() time.Time
}
func (s *expiringRefreshTokens) StoreRefreshToken(ctx context.Context, handle string, t *models.RefreshToken) error {
if t.CreationTime.IsZero() {
t.CreationTime = s.now().UTC()
}
if t.Lifetime == 0 {
t.Lifetime = int(s.ttl / time.Second)
}
return s.RefreshTokenStore.StoreRefreshToken(ctx, handle, t)
}
func (s *expiringRefreshTokens) GetRefreshToken(ctx context.Context, handle string) (*models.RefreshToken, error) {
t, err := s.RefreshTokenStore.GetRefreshToken(ctx, handle)
if err != nil {
return nil, err
}
if s.now().After(t.CreationTime.Add(time.Duration(t.Lifetime) * time.Second)) {
_ = s.RefreshTokenStore.RemoveRefreshToken(ctx, handle)
return nil, storage.ErrNotFound // 令牌端点据此返回 invalid_grant
}
return t, nil
}注入(inner 换成你的数据库实现;开发时可用 storage/impl 包的 NewRefreshTokenStore()):
cfg.SetRefreshTokenStore(&expiringRefreshTokens{
RefreshTokenStore: inner,
ttl: 30 * 24 * time.Hour,
now: time.Now,
})补上 CreationTime 与 Lifetime 之后,RemoveExpired 的结果也就正确了,可以放心由宿主定时调用。
相关阅读
- 授权码 + PKCE:刷新令牌的来源
- 存储端口:所有存储接口的约定
- 编写存储适配器:版本化更新的实现要点
- 生产部署清单