外观
多子系统 SSO 实战:OIDC 选型与统一登出避坑实录
问题背景:多子系统为什么不能各写各的登录
很多中后台在初期只有一个管理端,登录逻辑通常是账号密码换 Session。随着订单、结算、运营、数据看板等子系统拆分上线,如果每个系统都维护一套登录态,就会出现三个典型问题:用户需要重复登录;权限变更在各系统间不同步;退出一个系统后,其他系统仍保留登录状态。
SSO(Single Sign-On,单点登录)的目标是:一次认证,多个受信任系统共享身份结果;一次登出,相关系统尽可能同步清理会话。协议选择上,OIDC(OpenID Connect)建立在 OAuth 2.0 之上,用 Access Token 保护接口,用 ID Token 表达用户身份,再配合 Discovery、JWKS、Refresh Token、Back-Channel Logout 等机制,天然适合现代 Web、移动端和微服务架构。
下面先给出多子系统 SSO 的核心链路。
方案对比:CAS、SAML 与 OIDC 应该怎么选
CAS:简单但生态偏传统
CAS 的登录流程直观,业务系统通过 Ticket 与 CAS Server 交互完成认证。它适合单体或传统 Web 系统,但在移动 App、前后端分离、细粒度 API 授权方面不如 OAuth/OIDC 生态完善。
SAML:企业联邦能力强但开发成本高
SAML 2.0 在大型企业、教育和政府场景中很常见,尤其适合跨组织身份联邦。它使用 XML、签名和断言,安全性成熟,但 XML 解析、证书轮换、调试排障成本较高,对中小团队不够轻量。
OIDC:现代系统的性价比最高
OIDC 在 OAuth 2.0 上增加身份层,核心收益包括:
- Discovery 端点自动暴露授权、Token、JWKS、登出地址;
- ID Token 使用 JWT 格式,客户端可验证签发方、受众、过期时间和 nonce;
- Access Token 可用于网关到后端 API 的统一鉴权;
- Refresh Token 支持无感知续期;
- Back-Channel/Front-Channel Logout 可支撑统一登出。
如果团队主要面对浏览器、App、微服务和开放 API,OIDC 通常是首选。
实操步骤:Go 服务接入 OIDC 登录
示例使用 Go 1.22、github.com/coreos/go-oidc/v3/oidc 与标准库 net/http。身份提供方可以是 Keycloak、Authentik、Okta、Auth0 或自建 OIDC Authorization Server。
先初始化依赖:
bash
mkdir oidc-sso-demo && cd oidc-sso-demo
go mod init oidc-sso-demo
go get github.com/coreos/go-oidc/v3/oidc
go get golang.org/x/oauth2下面代码包含登录跳转、回调换码、ID Token 校验、State/Nonce 防 CSRF 与重放、受保护接口四个关键部分。
go
package main
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"log"
"net/http"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
var (
issuer = "https://idp.example.com/realms/prod"
clientID = "admin-console"
// 生产环境从密钥管理系统读取,不要写死在代码里
clientSecret = "change-me"
redirectURL = "http://localhost:8080/auth/callback"
)
var (
provider *oidc.Provider
oauthCfg *oauth2.Config
verifier *oidc.IDTokenVerifier
)
// 简化演示:生产环境建议放入 Redis,并设置过期时间
var stateStore = map[string]time.Time{}
var nonceStore = map[string]time.Time{}
func randomString() string {
b := make([]byte, 32)
_, _ = rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
func initOIDC() {
ctx := context.Background()
var err error
// 通过 .well-known/openid-configuration 自动发现端点
provider, err = oidc.NewProvider(ctx, issuer)
if err != nil {
log.Fatalf("OIDC Provider 初始化失败: %v", err)
}
oauthCfg = &oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
RedirectURL: redirectURL,
Endpoint: provider.Endpoint(),
Scopes: []string{oidc.ScopeOpenID, "profile", "email", "offline_access"},
}
verifier = provider.Verifier(&oidc.Config{
ClientID: clientID,
// 建议显式要求较短的时钟偏差窗口
SupportedSigningAlgs: []string{oidc.RS256},
})
}
func loginHandler(w http.ResponseWriter, r *http.Request) {
state := randomString()
nonce := randomString()
stateStore[state] = time.Now().Add(10 * time.Minute)
nonceStore[nonce] = time.Now().Add(10 * time.Minute)
// 生产环境应把 state/nonce 写入安全 Cookie 或 Redis
http.SetCookie(w, &http.Cookie{
Name: "oidc_nonce",
Value: nonce,
Path: "/",
MaxAge: 600,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
url := oauthCfg.AuthCodeURL(
state,
oidc.Nonce(nonce),
oauth2.SetAuthURLParam("prompt", "consent"),
)
http.Redirect(w, r, url, http.StatusFound)
}
func callbackHandler(w http.ResponseWriter, r *http.Request) {
state := r.URL.Query().Get("state")
code := r.URL.Query().Get("code")
if _, ok := stateStore[state]; !ok {
http.Error(w, "非法或过期的 state", http.StatusBadRequest)
return
}
delete(stateStore, state)
token, err := oauthCfg.Exchange(r.Context(), code)
if err != nil {
http.Error(w, "授权码交换失败: "+err.Error(), http.StatusBadGateway)
return
}
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
http.Error(w, "响应中缺少 id_token", http.StatusBadGateway)
return
}
idToken, err := verifier.Verify(r.Context(), rawIDToken)
if err != nil {
http.Error(w, "ID Token 校验失败: "+err.Error(), http.StatusUnauthorized)
return
}
nonceCookie, err := r.Cookie("oidc_nonce")
if err != nil || nonceCookie.Value != idToken.Nonce {
http.Error(w, "nonce 不匹配", http.StatusUnauthorized)
return
}
delete(nonceStore, idToken.Nonce)
var claims struct {
Sub string `json:"sub"`
Email string `json:"email"`
Name string `json:"name"`
}
if err := idToken.Claims(&claims); err != nil {
http.Error(w, "用户信息解析失败", http.StatusBadGateway)
return
}
// 演示环境写入 Cookie;生产环境建议保存自己的会话 ID,
// 后端通过会话关联 Access Token、Refresh Token 与 ID Token SID
sessionID := randomString()
sessionData := map[string]any{
"sub": claims.Sub,
"email": claims.Email,
"access_token": token.AccessToken,
"sid": idToken.SessionID,
}
payload, _ := json.Marshal(sessionData)
_ = payload
http.SetCookie(w, &http.Cookie{
Name: "SESSION",
Value: sessionID,
Path: "/",
MaxAge: 3600,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
w.Write([]byte("登录成功:" + claims.Email))
}
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("SESSION")
if err != nil {
http.Error(w, "请先登录", http.StatusUnauthorized)
return
}
// 生产环境从 Redis 查询会话,并检查是否已被统一登出标记失效
if cookie.Value == "" {
http.Error(w, "会话不存在", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
func main() {
initOIDC()
mux := http.NewServeMux()
mux.HandleFunc("/auth/login", loginHandler)
mux.HandleFunc("/auth/callback", callbackHandler)
mux.Handle("/api/profile", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("已通过 SSO 登录"))
})))
log.Println("listening on http://localhost:8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}网关鉴权:不要把 JWT 解析分散到每个业务服务
多子系统场景中,更推荐由 API Gateway 或统一 Auth Middleware 校验 Access Token,业务服务只接收网关注入后的用户身份 Header,例如 X-User-Id、X-Tenant-Id、X-Scopes。这样可以统一处理 JWKS 缓存、签名算法、受众、时钟偏移和封禁逻辑,避免每个团队各写一套解析代码。
资源服务校验 JWT 时至少检查:
iss是否等于可信 OIDC Issuer;aud是否包含当前 API 客户端或资源服务标识;exp、nbf、iat是否有效;- 签名算法是否固定为预期算法,不能接受
none; - Access Token 是否在网关或鉴权服务的吊销列表中。
统一登出:比单点登录更容易被低估
统一登出需要同时处理三层状态:IdP 全局 SSO 会话、业务系统本地会话、API Access Token。只清理浏览器 Cookie 并不等于真正登出,因为 Access Token 在过期前仍可能被非法重放。
推荐组合如下:
- 用户点击退出时,业务系统先清理本地 Session,再跳转 OIDC
end_session_endpoint; - IdP 清理 SSO Cookie,并通过 Back-Channel Logout 通知所有客户端;
- 各客户端根据 ID Token 中的
sid或sub删除本地会话; - 对高权限接口使用短生命周期 Access Token,并在网关维护 Redis 吊销集合;
- Refresh Token 必须支持服务端旋转与重用检测,一旦发现旧 Refresh Token 被再次使用,应撤销同一会话链路。
下面是一个 Back-Channel Logout 接收端的核心示例。
go
func backChannelLogoutHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "only post", http.StatusMethodNotAllowed)
return
}
// 不同 IdP 参数名可能不同,Keycloak 常见为 logout_token
logoutToken := r.FormValue("logout_token")
if logoutToken == "" {
http.Error(w, "missing logout_token", http.StatusBadRequest)
return
}
// Logout Token 仍需验签,并校验 iss、aud、events、iat、jti
token, err := verifier.Verify(r.Context(), logoutToken)
if err != nil {
http.Error(w, "logout token invalid", http.StatusUnauthorized)
return
}
var claims struct {
Sub string `json:"sub"
Events map[string]struct{} `json:"events"`
SID string `json:"sid"`
}
if err := token.Claims(&claims); err != nil {
http.Error(w, "claims invalid", http.StatusBadRequest)
return
}
if _, ok := claims.Events["http://schemas.openid.net/event/backchannel-logout"]; !ok {
http.Error(w, "unsupported event", http.StatusBadRequest)
return
}
// 优先按 sid 删除当前 IdP 会话对应的所有业务系统会话;
// 没有 sid 时再退化为 sub,影响范围更大,需要谨慎。
if claims.SID != "" {
// redis.Del(ctx, "sso:sessions:"+claims.SID)
// redis.Set(ctx, "revoked:sid:"+claims.SID, "1", 2*time.Hour)
}
w.WriteHeader(http.StatusOK)
}避坑总结:统一登出落地的 5 个关键注意事项
1. 只跳登录页,不清理业务 Session
常见现象是用户退出后访问业务系统,又被自动跳到 IdP 并静默登录回来,看起来像“退出失败”。根因通常是业务系统 Cookie 没删,或 IdP SSO Session 仍存在。修正方式是业务端先使本地 Session 失效,再调用 end_session_endpoint,并带上 id_token_hint 与 post_logout_redirect_uri。
2. 误把 ID Token 当 Access Token 调接口
ID Token 面向客户端,作用是声明“谁完成了认证”;Access Token 才面向资源服务。错误用法会导致接口无法按 Scope 授权,也无法细粒度吊销。网关应只接受 Access Token,业务前端不要依赖解析 ID Token 做接口鉴权。
3. JWT 只看过期时间,不校验受众和签名算法
有些服务只解析 JWT 并判断 exp,攻击者可能伪造其他 Issuer 的 Token,或使用异常算法构造令牌。必须固定签名算法为 RS256/ES256,校验 iss、aud、Kid 与 JWKS,并禁止 alg=none。
4. Refresh Token 长期有效且不轮换
Refresh Token 一旦泄露,危害比短寿 Access Token 更大。实践中应启用 Refresh Token Rotation,刷新后旧令牌立即失效;检测到旧令牌重放时,撤销该用户或设备会话链,并记录安全审计日志。
5. 登出通知失败没有补偿机制
Back-Channel Logout 是服务端到服务端调用,可能超时或失败。不能假设通知一定成功。应让 IdP 重试,客户端处理幂等;同时业务网关对短时间内无法确认状态的会话,结合 Redis 吊销列表和本地会话 TTL 做兜底,避免“IdP 已退出、子系统仍可用”的窗口长期存在。
综上,OIDC 并不是简单接入一个登录按钮,而是要把认证、授权、会话、续期、吊销和登出作为完整身份链路设计。多子系统场景下,建议以统一 IdP 为身份源、网关集中校验 Token、各系统保存可撤销的本地会话,并通过 Back-Channel Logout 与短寿令牌完成统一登出闭环。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。