持久化存储
wicket 的授权码、刷新令牌、会话、密钥等状态都通过存储端口读写。本页说明不持久化会出什么问题,以及如何用官方 PostgreSQL 适配器 github.com/gonewx/wicket-pg 完成生产装配。
为什么需要
| 端口族 | 存的是什么 | 只放在进程内存里的后果 |
|---|---|---|
storage | 授权码、刷新令牌、引用令牌、用户同意、持久化授权、设备码、CIBA 请求 | 重启后已签发的刷新令牌全部失效;多副本下授权码在 A 副本签发、换令牌请求打到 B 副本时查不到,用户看到随机失败 |
session | 服务端会话记录 | 重启即全员登出;多副本间会话不共享 |
keymgmt | 自动管理的签名密钥 | 每次重启换钥,已签发的 JWT 无法验签;各副本各自建钥 |
只使用无状态验签的宿主不需要本页。 oidcverify、wechatverify、phoneverify 三个包不依赖任何存储;如果你只是“把外部令牌验签后换成本地身份”,不需要装配授权服务器,也就不需要存储。见 本地验签与服务端交换。
11 个存储注入点
cfg.Freeze() 校验以下 11 个 seam 是否都已注入:
| seam | 注入方法 | wicket-pg 是否提供 |
|---|---|---|
| authorization code store | SetAuthorizationCodeStore | 是:store.NewAuthorizationCodeStore |
| refresh token store | SetRefreshTokenStore | 是:store.NewRefreshTokenStore |
| reference token store | SetReferenceTokenStore | 是:store.NewReferenceTokenStore |
| user consent store | SetUserConsentStore | 是:store.NewUserConsentStore |
| persisted grant store | SetPersistedGrantStore | 是:store.NewPersistedGrantStore |
| device flow store | SetDeviceFlowStore | 是:store.NewDeviceFlowStore |
| backchannel authentication request store | SetBackchannelAuthenticationRequestStore | 是:store.NewBackchannelAuthenticationRequestStore |
| session store | SetSessionStore | 是:store.NewSessionStore |
| key record store | SetRecordStore | 是:store.NewKeyRecordStore |
| authorization parameters message store | SetAuthorizationParametersMessageStore[Provider] | 否,宿主自行处理 |
| distributed cache | SetDistributedCache[Provider] | 否,宿主自行处理 |
缺任何一个,又没有调用 cfg.AllowInMemoryFallback(),Freeze() 就会 panic:
panic: store seam "authorization parameters message store" not injected by host and in-memory fallback not declared所以只注入 wicket-pg 的 9 个 store、不处理剩下两个,Freeze() 必然失败。处理方法见下文。
开发环境:内存回退
cfg.AllowInMemoryFallback() // 仅限本地开发,必须在 Freeze 之前调用
cfg.Freeze()声明回退后,未注入的 store 在首次使用时换成进程内存实现,并输出一条 warn 日志。生产装配不要调用它:它对所有未注入的 seam 一视同仁,会把“忘了注入某个 store”变成一个只在多副本或重启后才暴露的隐患。
官方 PostgreSQL 适配器
github.com/gonewx/wicket-pg 实现了上表中的 9 个 store,自带迁移。要求 PostgreSQL 15+、Go 1.27。当前版本 v0.1.3,依赖 wicket v0.1.4。
GOWORK=off go get github.com/gonewx/wicket-pg@v0.1.31. 执行迁移
迁移随适配器分发,表的所有权属于适配器。宿主在启动序列中显式调用;migrations.Up 幂等(已应用的版本记录在 schema_migrations 表中),不会自动触发:
import (
"github.com/gonewx/wicket-pg/migrations"
"github.com/jackc/pgx/v5/pgxpool"
)
pool, err := pgxpool.New(ctx, dsn) // 连接池由宿主创建和关闭
if err != nil {
log.Fatal(err)
}
defer pool.Close()
if err := migrations.Up(ctx, pool); err != nil {
log.Fatal(err)
}需要回滚时调用 migrations.Down(ctx, pool),它会回滚全部已应用的版本。
2. 构造并注入
每个构造器签名都是 store.NewXxxStore(pool *pgxpool.Pool, logger *slog.Logger),logger 传 nil 时使用 slog.Default()。建议把适配器保存在一个结构体里——清理循环和合规校验都要用到它们的具体类型:
import (
"github.com/gonewx/wicket-pg/store"
"github.com/gonewx/wicket/di"
servicesmemory "github.com/gonewx/wicket/services/memory"
"github.com/gonewx/wicket/storage"
)
type pgStores struct {
authorizationCode *store.AuthorizationCodeStore
refreshToken *store.RefreshTokenStore
referenceToken *store.ReferenceTokenStore
userConsent *store.UserConsentStore
persistedGrant *store.PersistedGrantStore
deviceFlow *store.DeviceFlowStore
backchannel *store.BackchannelAuthenticationRequestStore
session *store.SessionStore
keyRecord *store.KeyRecordStore
}
func newPGStores(pool *pgxpool.Pool, logger *slog.Logger) *pgStores {
return &pgStores{
authorizationCode: store.NewAuthorizationCodeStore(pool, logger),
refreshToken: store.NewRefreshTokenStore(pool, logger),
referenceToken: store.NewReferenceTokenStore(pool, logger),
userConsent: store.NewUserConsentStore(pool, logger),
persistedGrant: store.NewPersistedGrantStore(pool, logger),
deviceFlow: store.NewDeviceFlowStore(pool, logger),
backchannel: store.NewBackchannelAuthenticationRequestStore(pool, logger),
session: store.NewSessionStore(pool, logger),
keyRecord: store.NewKeyRecordStore(pool, logger),
}
}
func (s *pgStores) inject(cfg *di.ServerConfig) {
cfg.SetAuthorizationCodeStore(s.authorizationCode)
cfg.SetRefreshTokenStore(s.refreshToken)
cfg.SetReferenceTokenStore(s.referenceToken)
cfg.SetUserConsentStore(s.userConsent)
cfg.SetPersistedGrantStore(s.persistedGrant)
cfg.SetDeviceFlowStore(s.deviceFlow)
cfg.SetBackchannelAuthenticationRequestStore(s.backchannel)
cfg.SetSessionStore(s.session)
cfg.SetRecordStore(s.keyRecord) // 注意方法名是 SetRecordStore
// wicket-pg 不覆盖的两个 seam,显式声明(理由见下节)
cfg.SetAuthorizationParametersMessageStoreProvider(func() storage.AuthorizationParametersMessageStore {
return nil // 不使用参数消息存储:授权参数直接编码进登录页的 returnUrl
})
cfg.SetDistributedCache(servicesmemory.NewMemoryDistributedCache())
}// 示意:启动序列中的调用顺序
pg := newPGStores(pool, logger)
cfg := root.ServerConfig()
pg.inject(cfg)
// ... 客户端、资源、签名密钥 ...
cfg.Freeze() // 不调用 AllowInMemoryFallback 也能通过上面这套装配已在 v0.1.4 + wicket-pg v0.1.3 + PostgreSQL 16 上实测:不声明内存回退时 Freeze() 通过,迁移重复执行无副作用,令牌签发、内省与清理均正常。
3. 剩下两个 seam 怎么处理
授权参数消息存储(storage.AuthorizationParametersMessageStore):可选组件。注入时,wicket 把授权请求参数存进去,只把一个句柄放进登录页的 returnUrl;为 nil 时,参数直接以查询串形式编码进 returnUrl。wicket 没有提供它的内存实现——开发环境走内存回退时它本来就是 nil。用一个返回 nil 的 provider 注入,行为与开发环境一致,同时满足 Freeze 的校验。如果授权请求参数很长、returnUrl 超出你的网关限制,再自行实现该接口(Write / Read / Delete 三个方法),并由宿主负责关闭它——容器不会释放这个 seam。
分布式缓存(services.DistributedCache):接口只有 Get、Set、Remove 三个方法。v0.1.4 中它只用于设备码轮询的限速。注入 services/memory.NewMemoryDistributedCache() 时,每个副本各有一份,限速按副本独立计算;如果你要在多副本下严格执行设备码限速,可用 Redis 等实现该接口后注入。
4. 校验适配器合规性(可选)
每个 wicket-pg store 都暴露 ConformsTo(),返回它所通过的契约测试套件版本。启动时与 wicket 的套件版本比对,可以防止适配器与 wicket 版本错配:
import (
"github.com/gonewx/wicket/keymgmt/keymgmttest"
"github.com/gonewx/wicket/session/sessiontest"
"github.com/gonewx/wicket/storage/storagetest"
)
func (s *pgStores) checkConformance() error {
checks := []struct{ name, got, want string }{
{"authorization code", s.authorizationCode.ConformsTo(), storagetest.SuiteVersion},
{"refresh token", s.refreshToken.ConformsTo(), storagetest.SuiteVersion},
{"reference token", s.referenceToken.ConformsTo(), storagetest.SuiteVersion},
{"user consent", s.userConsent.ConformsTo(), storagetest.SuiteVersion},
{"persisted grant", s.persistedGrant.ConformsTo(), storagetest.SuiteVersion},
{"device flow", s.deviceFlow.ConformsTo(), storagetest.SuiteVersion},
{"backchannel", s.backchannel.ConformsTo(), storagetest.SuiteVersion},
{"session", s.session.ConformsTo(), sessiontest.SuiteVersion},
{"key record", s.keyRecord.ConformsTo(), keymgmttest.SuiteVersion},
}
for _, c := range checks {
if c.got != c.want {
return fmt.Errorf("adapter %s conforms to %q, wicket requires %q", c.name, c.got, c.want)
}
}
return nil
}过期清理由宿主驱动
wicket 不启动后台 goroutine,也没有一个统一的 root.CleanupExpired 方法。过期判定始终在 wicket 核心侧完成(适配器不会在查询里按时间过滤),清理只是回收空间、不影响正确性。回收入口在各个 store 上:
storage族的 7 个 store:RemoveExpired(ctx, cutoff time.Time) (int, error)- 会话 store:
DeleteExpired(ctx, cutoff time.Time) (int, error) - 密钥记录 store:接口上没有按时间回收的方法,密钥管理器也不会删除记录;退役(retired)的密钥记录会一直保留,如需清理请宿主自行处理
type expirer interface {
RemoveExpired(ctx context.Context, cutoff time.Time) (int, error)
}
func (s *pgStores) cleanup(ctx context.Context, now time.Time, logger *slog.Logger) {
stores := map[string]expirer{
"authorization_code": s.authorizationCode,
"refresh_token": s.refreshToken,
"reference_token": s.referenceToken,
"user_consent": s.userConsent,
"persisted_grant": s.persistedGrant,
"device_flow": s.deviceFlow,
"backchannel": s.backchannel,
}
for name, st := range stores {
n, err := st.RemoveExpired(ctx, now)
if err != nil {
logger.Error("cleanup failed", "store", name, "err", err)
continue
}
logger.Debug("cleanup done", "store", name, "removed", n)
}
if n, err := s.session.DeleteExpired(ctx, now); err != nil {
logger.Error("cleanup failed", "store", "session", "err", err)
} else {
logger.Debug("cleanup done", "store", "session", "removed", n)
}
}
// runCleanup 在宿主自己的 goroutine 中运行,ctx 取消即退出。
func runCleanup(ctx context.Context, s *pgStores, every time.Duration, logger *slog.Logger) {
t := time.NewTicker(every)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case now := <-t.C:
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
s.cleanup(cctx, now, logger)
cancel()
}
}
}// 示意:在启动序列中启动清理循环
cleanupCtx, cancelCleanup := context.WithCancel(context.Background())
go runCleanup(cleanupCtx, pg, 10*time.Minute, logger)
// 关闭时:cancelCleanup()多副本部署时每个副本都跑清理也是安全的(删除操作幂等),但会产生重复的数据库负载;可以只在一个副本上运行,或用数据库咨询锁做选主。
资源释放
- 连接池归宿主所有。 wicket-pg 的 store 不实现
Close,root.Close()不会关闭pgxpool.Pool。宿主在root.Close()之后自行pool.Close()。 - 宿主自己实现的 store 如果实现了
io.Closer,root.Close()会按逆序调用它的Close(授权参数消息存储除外,它始终由宿主关闭)。
关闭顺序见 嵌入宿主应用。
编写其他介质的适配器
需要 MySQL、Redis 等其他介质时,请按契约测试套件实现端口,而不是在宿主里随手写一份。要点(不存在与故障必须可区分、写入默认 insert-only、版本号并发控制、过期判定留在核心侧等)见 编写存储适配器 与 存储端口。