跳到主内容

多子系统 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 的核心链路。

用户浏览器访问业务系统业务子系统校验本地会话OIDC 身份提供方登录与签发 Token网关或资源服务JWKS 校验令牌统一登出通道清理各系统会话

方案对比: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 时至少检查:

  1. iss 是否等于可信 OIDC Issuer;
  2. aud 是否包含当前 API 客户端或资源服务标识;
  3. exp、nbf、iat 是否有效;
  4. 签名算法是否固定为预期算法,不能接受 none;
  5. 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 与短寿令牌完成统一登出闭环。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。