存储端口
wicket 把所有需要持久化的状态抽象成 Go 接口(端口),宿主通过 ServerConfig 注入实现。本页是这些接口的速查:每个接口管什么、有哪些方法、出错时返回什么,以及适配器必须遵守的语义。要动手写一个适配器,请接着看编写存储适配器。
端口分三族,各自有独立的哨兵错误:
| 族 | 包 | 接口 |
|---|---|---|
| 授权数据 | github.com/gonewx/wicket/storage | 7 个授权状态存储 + 消息存储 + 配置类存储 |
| 会话 | github.com/gonewx/wicket/session | session.Store |
| 密钥记录 | github.com/gonewx/wicket/keymgmt | keymgmt.RecordStore |
另有一个不属于以上三族、但同样需要注入的 services.DistributedCache,放在本页末尾单独说明。
哨兵错误
| 包 | 错误 | 含义 |
|---|---|---|
storage | ErrNotFound | 单条读取未命中 |
storage | ErrDuplicateHandle | insert-only 写入撞上已存在的句柄 |
storage | ErrVersionConflict | 乐观并发更新时版本号不匹配 |
session | ErrSessionNotFound | 会话不存在 |
keymgmt | ErrRecordNotFound | 密钥记录不存在 |
keymgmt | ErrDuplicateKey | 句柄或未退役的公开 kid 重复 |
keymgmt | ErrVersionConflict | 密钥记录版本冲突 |
注意三族的“未找到”是三个不同的值:会话存储返回 storage.ErrNotFound 不会被识别。keymgmt 包还定义了 ErrNoSigningKey、ErrAlgorithmUnavailable、ErrConfiguration、ErrProtectorUnavailable,它们由密钥管理器返回,不是存储适配器该用的。判断时一律用 errors.Is,适配器可以包装(fmt.Errorf("...: %w", storage.ErrNotFound))。
契约语义
以下规则对三族端口都成立(DistributedCache 例外,见末尾)。
“不存在”与“故障”必须可区分。 单条读取未命中时返回对应哨兵错误,绝不返回 (nil, nil);数据库连接失败等返回其他错误。否则一次网络抖动会被核心当成“授权码无效”,用户看到登录失败而运维侧没有任何报警。列表类方法没有结果时返回空切片而不是哨兵(storage 族要求非 nil 的空切片;会话族 nil 与空切片都接受)。
insert-only 写入。 以调用方生成的句柄为键的写入只插入、不覆盖,句柄已存在即返回冲突错误且不改动原记录。刷新令牌、授权码的一次性语义依赖这一点;把它实现成 upsert(例如键值库里直接 SET)会让重放防线静默失效。显式声明为 upsert 的只有两个:PersistedGrantStore.Store(按 grant key)和 UserConsentStore.StoreUserConsent(按 subject + client)。
版本号并发控制。 读-改-写类更新带 expectedVersion:只有存储中的版本等于它时才写入,并把版本加一、回写到调用方对象上;否则返回 ErrVersionConflict 且不改动记录。存储层自己不解决冲突。以刷新令牌为例,wicket 核心不会重试,而是把冲突转成令牌端点的 invalid_grant——两个并发刷新请求只有一个成功。
超时由调用方的 ctx 决定。 每个方法第一个参数都是 context.Context,取消与截止时间要一路传到底层驱动。适配器不得自己 context.Background(),也不得设置覆盖调用方的超时。
过期判定在核心侧。 读取时不论记录是否过期都照常返回;不要在查询里追加 expires_at > now() 之类的条件。数据库时钟与应用节点时钟会漂移,统一由核心用注入的时钟判断才一致。有生命周期的数据提供 RemoveExpired / DeleteExpired(ctx, cutoff) 用于回收空间,它只删除早于 cutoff 的记录、返回删除条数,不承担正确性。
不自起后台任务。 适配器不得启动后台 goroutine 或定时器;过期回收由宿主按自己的调度调用上述回收方法。契约套件会检查这一点。
并发安全。 存储实例在容器中是单例,会被所有请求并发调用。
storage 族:授权状态
模型类型位于 github.com/gonewx/wicket/storage/models。
AuthorizationCodeStore
StoreAuthorizationCode(ctx, handle string, code *models.AuthorizationCode) error // insert-only
GetAuthorizationCode(ctx, handle string) (*models.AuthorizationCode, error) // 返回独立副本
RemoveAuthorizationCode(ctx, handle string) error
RemoveExpired(ctx, cutoff time.Time) (int, error) // 创建时间 + 有效期 早于 cutoffRefreshTokenStore
StoreRefreshToken(ctx, handle string, token *models.RefreshToken) error // insert-only,存储副本版本为 1 并回写
GetRefreshToken(ctx, handle string) (*models.RefreshToken, error)
UpdateRefreshToken(ctx, handle string, token *models.RefreshToken, expectedVersion int) error // 版本控制
RemoveRefreshToken(ctx, handle string) error
RemoveExpired(ctx, cutoff time.Time) (int, error)这是 storage 族中唯一带版本号的接口。
ReferenceTokenStore
StoreReferenceToken(ctx, handle string, token *models.Token) error // insert-only
GetReferenceToken(ctx, handle string) (*models.Token, error)
RemoveReferenceToken(ctx, handle string) error
RemoveReferenceTokens(ctx, subjectId, clientId string) error // 按 subject + client 批量吊销
RemoveExpired(ctx, cutoff time.Time) (int, error)UserConsentStore
StoreUserConsent(ctx, consent *models.Consent) error // upsert:按 subject + client
GetUserConsent(ctx, subjectId, clientId string) (*models.Consent, error)
GetAllUserConsents(ctx, subjectId string) ([]*models.Consent, error)
RemoveUserConsent(ctx, subjectId, clientId string) error
RemoveExpired(ctx, cutoff time.Time) (int, error) // 仅 Expiration 已设置的记录PersistedGrantStore
Store(ctx, grant *models.PersistedGrant) error // upsert:按 grant.Key
Get(ctx, key string) (*models.PersistedGrant, error)
GetAll(ctx, filter *storage.PersistedGrantFilter) ([]*models.PersistedGrant, error)
Remove(ctx, key string) error
RemoveAll(ctx, filter *storage.PersistedGrantFilter) error
RemoveExpired(ctx, cutoff time.Time) (int, error)PersistedGrantFilter 的字段为 SubjectId、SessionId、ClientId、ClientIds、Type、Types,GetAll / RemoveAll 必须精确匹配所有非空条件。
DeviceFlowStore
StoreDeviceAuthorization(ctx, deviceCode, userCode string, data *models.DeviceCode) error // insert-only,任一 code 冲突即失败
FindByUserCode(ctx, userCode string) (*models.DeviceCode, error)
FindByDeviceCode(ctx, deviceCode string) (*models.DeviceCode, error)
UpdateByUserCode(ctx, userCode string, data *models.DeviceCode) error // 状态迁移,无版本号
RemoveByDeviceCode(ctx, deviceCode string) error // 两个 code 的索引一并清除
RemoveExpired(ctx, cutoff time.Time) (int, error)BackchannelAuthenticationRequestStore
StoreBackchannelAuthenticationRequest(ctx, requestID string, data *models.BackchannelAuthenticationRequest) error // insert-only
FindBackchannelAuthenticationRequest(ctx, requestID string) (*models.BackchannelAuthenticationRequest, error)
UpdateBackchannelAuthenticationRequest(ctx, requestID string, data *models.BackchannelAuthenticationRequest) error // 无版本号
RemoveBackchannelAuthenticationRequest(ctx, requestID string) error
RemoveExpired(ctx, cutoff time.Time) (int, error)设备流与 CIBA 的更新不带版本号:它们的一次性语义由状态字段和删除保证。
删除不存在的记录:wicket 自带的内存实现返回 nil(幂等)。
storage 族:消息与配置
| 接口 | 方法 | 说明 |
|---|---|---|
AuthorizationParametersMessageStore | Write(ctx, *messages.Message[url.Values]) (string, error)、Read(ctx, handle)、Delete(ctx, handle) | 跨登录重定向保存授权参数。未注入时参数直接放在回调 URL 中 |
MessageStore[T] | Write、Read | 内部消息(登出、错误等),v0.1.4 由内置实现承担,无注入缝 |
ClientStore | FindClientById(ctx, clientId) | 由 SetClients 数据构造,无独立注入缝 |
ResourceStore | GetAllEnabledResources、FindResourcesByScope、FindApiResourcesByName、FindApiScopesByName、FindIdentityResourcesByScope | 由 SetIdentityScopes 等数据构造,无独立注入缝 |
SigningCredentialStore | GetSigningCredentials(ctx) (*tokens.SigningCredentials, error) | 见签名密钥 |
ValidationKeysStore | GetValidationKeys(ctx) ([]*models.SecurityKeyInfo, error) | 同上 |
消息与配置类数据没有服务端生命周期,因此没有回收方法。
session 族
type Store interface {
GetSession(ctx, sessionID string) (*session.Record, error) // 未命中返回 ErrSessionNotFound
CreateSession(ctx, s *session.Record) error // insert-only
UpdateSession(ctx, s *session.Record) error
DeleteSession(ctx, sessionID string) error
GetSessionsBySubjectID(ctx, subjectID string) ([]*session.Record, error)
DeleteSessionsBySubjectID(ctx, subjectID string) (int, error)
AddClientID(ctx, sessionID, clientID string) error // 原子追加并去重
DeleteExpired(ctx, cutoff time.Time) (int, error)
}与 storage 族的差异:
CreateSession遇到重复 id 只要求返回非 nil 错误且不改动原记录,没有规定具体哨兵。UpdateSession、DeleteSession、AddClientID作用于不存在的会话时是无操作,不报错。- 没有版本号;
AddClientID的原子性由存储自身保证(例如单条 SQL 的数组追加或事务)。 - 过期判定在
session.Coordinator.ValidateSession中用注入的时钟完成,存储只负责保存Expires。
session.Record 的字段:SessionID、SubjectID、DisplayName、Created、Renewed、Expires、ClientIDs。
keymgmt 族
type RecordStore interface {
List(ctx) ([]*keymgmt.Record, error)
Get(ctx, handle string) (*keymgmt.Record, error) // 未命中返回 ErrRecordNotFound
Create(ctx, record *keymgmt.Record) error // insert-only,成功后 Version 为 1
Update(ctx, record *keymgmt.Record, expectedVersion uint64) error
Delete(ctx, handle string) error // 未命中返回 ErrRecordNotFound
}要点:
Create在句柄重复,或PublicID(JWKS 中的 kid)与一条未退役记录重复时返回ErrDuplicateKey;已退役记录的 kid 可以复用。Update、Delete作用于不存在的记录返回ErrRecordNotFound——这一点与会话族的“无操作”相反。Version是uint64,每次写入加一,与表示私钥保护方案的Format字段无关。- 所有返回值必须是深拷贝,调用方修改不能影响存储内容。
- 存储不按生命周期窗口过滤,也没有回收方法:密钥退役由管理器在核心侧推进,存储保留记录直到被显式
Delete。 ProtectedPrivateMaterial是已加密保护的私钥字节,按原样存取即可;PublicMaterial是明文公钥。
DistributedCache(例外)
type DistributedCache interface {
Get(key string) ([]byte, error)
Set(key string, value []byte, opts *services.DistributedCacheEntryOptions) error
Remove(key string) error
}v0.1.4 中它只被设备流的轮询节流使用,契约与上面三族不同:
- 方法没有
context.Context参数; - 未命中返回
(nil, nil),不是哨兵错误; - 缓存自己按
AbsoluteExpiration判定过期(内置内存实现在读取时淘汰过期项)。
它不在三套契约套件的覆盖范围内。多副本部署时可直接映射到 Redis 一类带 TTL 的键值存储。