会话与登出
本页说明用户如何从 wicket 登出、宿主登出页需要做什么,并如实列出 v0.1.4 中单点登出相关能力的实现程度。结论是:基于 /connect/endsession 的登出可以工作;前通道、后通道通知和 checksession 在 v0.1.4 中实际不会生效,尽管发现文档宣称支持。
本页的请求与结果来自在 v0.1.4 上实际运行的实验程序(在 授权码 + PKCE 的环境上继续操作)。
实现程度一览
| 能力 | 发现文档 | v0.1.4 实际 |
|---|---|---|
end_session_endpoint(/connect/endsession) | 列出 | 可用:校验参数后重定向到宿主登出页 |
post_logout_redirect_uri + state | — | 可用,需配合 id_token_hint |
frontchannel_logout_supported | true | 不生效:实测登出上下文中的 SignOutIFrameUrl 为空,不会触发前通道通知 |
backchannel_logout_supported | true | 未实现:发送通知的服务是空实现,且没有调用方 |
check_session_iframe(/connect/checksession) | 列出 | 页面可访问,但授权响应不返回 session_state,无法使用 |
服务端会话 session.Store | — | 可写入记录;吊销需宿主配合(见下文) |
前通道无法生效的直接原因是:用户会话里“登录过哪些客户端”的列表不会跨请求保留,登出时这个列表为空。
1. 配置
opts.UserInteraction.LogoutUrl = "/account/logout" // 默认是空字符串,必须设置
// LogoutIdParameter 默认 "logoutId"
web.PostLogoutRedirectUris = []string{"http://127.0.0.1:8080/signed-out"}2. 客户端发起登出
GET /connect/endsession
?id_token_hint=eyJhbGciOiJSUzI1NiIs...
&post_logout_redirect_uri=http%3A%2F%2F127.0.0.1%3A8080%2Fsigned-out
&state=byeGET 与 POST(表单)均可。wicket 校验后重定向到宿主登出页(实测):
HTTP/1.1 302 Found
Location: /account/logout?logoutId=ZXlKRGNtVmhkR1ZrSWpveE56azBOVE01TXpFeUxD...| 参数 | 说明 |
|---|---|
id_token_hint | 之前签发的 id_token,用来识别客户端;不校验有效期,过期的也可以用。若当前浏览器已登录且 sub 不同,请求被拒绝 |
post_logout_redirect_uri | 仅在带 id_token_hint 且地址在该客户端 PostLogoutRedirectUris 中时生效;不匹配时静默忽略(实测登出上下文中该字段为空) |
state | 只有 post_logout_redirect_uri 生效时才会被附加到回跳地址 |
ui_locales | 透传给登出页 |
不带任何参数也能访问,此时直接重定向到 /account/logout,没有 logoutId。
3. 宿主登出页
登出页用 logoutId 读取上下文,清除宿主的登录 Cookie,然后决定是否回跳客户端:
app.HandleFunc("/account/logout", func(w http.ResponseWriter, r *http.Request) {
sp := hosting.ScopeFrom(r.Context())
lr, err := godi.GetRequired[services.InteractionService](sp).
GetLogoutContext(r.Context(), r.URL.Query().Get("logoutId"))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// lr.ShowSignoutPrompt() 为 true(没有可识别的客户端)时,建议先让用户确认,
// 防止第三方页面诱导登出。
if err := godi.GetRequired[auth.Service](sp).SignOut(r, w,
wicket.DefaultCookieAuthenticationScheme, nil); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if lr.PostLogoutRedirectUri != "" {
http.Redirect(w, r, lr.PostLogoutRedirectUri, http.StatusFound)
return
}
// 渲染“已登出”页面;lr.SignOutIFrameUrl 非空时应以隐藏 iframe 嵌入(v0.1.4 中总为空)
})实测登出上下文:ClientId 为 web-app,PostLogoutRedirectUri 为 http://127.0.0.1:8080/signed-out?state=bye(state 已附加好),SignOutIFrameUrl、SubjectId、SessionId 均为空,ClientIds 为空列表。登出页执行 SignOut 后,再访问 /connect/authorize 会重新跳转到登录页。
登出要由宿主完成
/connect/endsession 本身不会清除任何 Cookie 或会话,它只负责校验参数并把用户交给登出页。真正的登出发生在你调用 auth.Service.SignOut(即你的 Cookie 处理器的 SignOut)时。
4. 前通道、后通道与 checksession
为了让你评估影响,这里说明 v0.1.4 中的现状:
- 前通道:
Client.FrontChannelLogoutUri可以配置,/connect/endsession/callback?endSessionId=...端点也已注册。回调页本身能工作:wicket 的集成测试直接写入一份通知上下文时,它会为每个客户端输出一个带iss、sid参数的隐藏 iframe。但在真实的登出流程中,这份上下文需要“会话 ID + 登录过的客户端列表”,而两者在 v0.1.4 中都是空的(无论是否启用服务端会话,实测都一样),所以登出页拿到的SignOutIFrameUrl为空,前通道通知不会发出。 - 后通道:
Client.BackChannelLogoutUri可以配置,cfg.SetBackChannelLogoutHttpClient也存在,但负责发送登出令牌的服务直接返回成功、不发送任何请求,也没有代码调用它。 - checksession:
GET /connect/checksession返回会话检查 iframe 页面,读取名为wicket.session的 Cookie。但授权响应中没有session_state参数,客户端无从比较;该 Cookie 也只在启用服务端会话时才会写入。
需要单点登出时,目前的做法是由宿主自行维护“用户登录过哪些客户端”,在登出页中通知这些客户端(例如调用各客户端自己的登出接口)。客户端侧应以较短的访问令牌有效期为主,不要依赖 wicket 的登出通知。
5. 服务端会话(session.Store)
启用后,每次登录(UserSession.CreateSessionId,即开发用 Cookie 处理器在 SignIn 中调用的方法)都会在 session.Store 中写入一条记录,并下发 wicket.session Cookie。
opts.ServerSideSessions.Enabled = true
// 默认:SessionLifetime 14 天;CookieName "wicket.session";
// CookieSecure true;CookieSameSite None。本地 http 调试时把 CookieSecure 设为 false。
cfg.SetSessionStore(myStore) // 实现 session.Store;开发时可用 session.NewMemoryStore(clock)session.Store 提供按会话 ID 的增删改查,以及 GetSessionsBySubjectID、DeleteSessionsBySubjectID(按用户批量吊销)和 DeleteExpired(回收空间,由宿主定时调用——wicket 不自起后台 goroutine)。
v0.1.4 中会话记录不会自动生效
实测启用服务端会话后:
- 登录时会写入记录(
SubjectID正确),但记录的ClientIDs始终为空; - 后续请求不会从 Cookie 恢复会话,签发的令牌中没有
sid,因此令牌校验时的会话检查从不触发; - 服务端会话记录不参与浏览器登录态的判断:按用户删除会话(
DeleteSessionsBySubjectID)本身不会让浏览器登出,已签发的令牌和刷新令牌也不受影响。
浏览器是否“已登录”完全由你的 Cookie 处理器的 Authenticate 决定。
要让“按用户吊销”对浏览器登录真正生效,让 Cookie 处理器在 Authenticate 时检查会话是否还在。在开发用处理器(见 授权码 + PKCE)基础上:保存 CreateSessionId 返回的会话 ID,每次认证时查一次存储。以下是需要改动的片段(示意:同样的逻辑已在实验程序中以普通 map 的写法运行验证,这里的 sync.Map 写法未单独编译):
type ticket struct {
p *claims.ClaimsPrincipal
sid string // CreateSessionId 的返回值
}
// SignIn 中:
sid, err := h.session.CreateSessionId(h.r.Context(), p, props)
if err != nil {
return err
}
tickets.Store(id, ticket{p: p, sid: sid})
// Authenticate 中,取到 ticket 之后:
if h.sessions != nil && t.sid != "" {
if _, err := h.sessions.GetSession(h.r.Context(), t.sid); err != nil {
return auth.NoResult(), nil // 会话已被吊销或过期:视为未登录
}
}
// 处理器工厂中注入存储:
// h.sessions = godi.GetRequired[session.Store](p)实测加上这项检查后,调用 DeleteSessionsBySubjectID(ctx, "carol") 删除 1 条会话,carol 的浏览器再次访问 /connect/authorize 就被重定向到登录页。对应地,在 SignOut 中调用 h.sessions.DeleteSession(ctx, t.sid) 可以在登出时同步清理记录(这一步为示意,未单独运行)。
注意这只影响浏览器登录态;已签发的访问令牌在过期前仍然有效,刷新令牌的处理见 刷新令牌。
相关阅读
- 授权码 + PKCE:Cookie 处理器与登录页
- 刷新令牌:登出不会吊销刷新令牌
- 发现文档:各字段与实际实现的对照
- 持久化存储:
session.Store等存储的生产实现