BOYYANG/1/blog/compressed/【彼岸图网】女孩 蓝色玫瑰花 背景图片 4K动漫壁纸3840x2160-896ad2555acc4e41aa4e89223c55c9d7

深入理解单点登录(SSO):从原理到 Go 语言实战

作者: boyyang
分类: 后端开发
发布: 2026-07-10 05:30:58
更新: 2026-07-10 05:30:58
浏览: 2

一、什么是单点登录

单点登录(Single Sign-On,简称 SSO)是一种身份验证方案,允许用户使用一组凭据(用户名和密码)登录一次,即可访问多个相互独立的系统。用户不需要为每个系统重复输入登录信息,大幅提升了用户体验和生产力。

与之相对的是 Single Logout(SLO,单点登出)——用户在一个系统登出时,所有关联系统也同步登出。

SSO 解决了什么问题?

  • 用户体验差:用户需要在 N 个系统记住 N 组密码
  • 安全风险高:密码复用、弱密码、凭据分散存储
  • 管理成本大:每个系统各自维护用户数据库、密码策略、MFA

SSO 的核心价值在于将认证逻辑集中化,让应用只关注授权(Authorization),将认证(Authentication)委托给统一的身份提供者(Identity Provider,IdP)。

二、SSO 的核心原理

基本架构

SSO 涉及三个角色:

+---------+          +-----------+          +-----------+
|  User   | <------> |  Service  | <------> | Identity  |
| (Browser)|          | Provider  |          | Provider  |
+---------+          |  (SP)     |          |  (IdP)    |
                     +-----------+          +-----------+
  1. User(用户):通过浏览器访问各应用
  2. Service Provider(SP,服务提供者):实际提供业务功能的应用,如 CRM、Wiki
  3. Identity Provider(IdP,身份提供者):统一管理用户认证的中心服务

核心流程(以基于 Ticket 的 SSO 为例)

步骤 1:用户访问 SP-A(未登录)
         ↓
步骤 2:SP-A 检测到无有效会话,将用户重定向到 IdP 登录页
         ↓
步骤 3:用户在 IdP 输入凭据,IdP 验证通过
         ↓
步骤 4:IdP 创建全局会话,生成一个 Ticket(票据),通过浏览器回传 SP-A
         ↓
步骤 5:SP-A 拿着 Ticket 向 IdP 验证,验证通过后为用户创建本地会话
         ↓
步骤 6:用户访问 SP-B(未在 SP-B 登录过)
         ↓
步骤 7:SP-B 将用户重定向到 IdP
         ↓
步骤 8:IdP 检测到用户已有全局会话(Cookie/Token),不再要求登录,直接签发 Ticket
         ↓
步骤 9:SP-B 用 Ticket 到 IdP 验证,成功后创建 SP-B 的本地会话

整个过程中,用户只在 IdP 输入了一次凭据,后续访问其他应用时 IdP 自动确认身份。

三、常见 SSO 协议与实现方式

1. OAuth 2.0

OAuth 2.0 本质上是授权协议,而非认证协议。但它常被用作 SSO 的基础,常见的授权码模式(Authorization Code Flow + PKCE)流程如下:

+-----------+                               +-------------+
|           |--(1)--- 授权请求 ----------->|             |
|           |                               |             |
|           |<-(2)--- 授权码 ---------------|    IdP      |
|           |                               |  (OAuth     |
|   应用    |--(3)--- 授权码 + Client Secret|   Server)   |
|           |                               |             |
|           |<-(4)--- Access Token ---------|             |
|           |                               +-------------+
|           |--(5)--- 携带 Token 访问资源 -->|  资源服务器  |
|           |<-(6)--- 用户信息 -------------|             |
+-----------+                               +-------------+

适用场景:授权委托、第三方登录("使用 Google 登录")

2. OpenID Connect (OIDC)

OIDC 在 OAuth 2.0 之上增加了一个 ID Token(JWT 格式),专门用于认证。OIDC 是目前最流行的 SSO 协议之一。

OIDC 的核心令牌:
- ID Token:JWT 格式,包含用户身份信息(sub、name、email 等)
- Access Token:用于访问用户信息端点(UserInfo Endpoint)
- Refresh Token:用于刷新 Access Token

关键端点:

- /authorize:授权端点

- /token:令牌端点

- /userinfo:用户信息端点

- /.well-known/openid-configuration:发现端点

3. SAML 2.0

SAML(Security Assertion Markup Language)基于 XML,在企业级应用中广泛使用。

SP 发起登录:
1. SP 生成 AuthnRequest(SAML 请求),重定向用户到 IdP
2. IdP 认证用户后,生成 SAML Assertion(包含用户属性 + 签名)
3. 用户通过 POST 将 Assertion 提交给 SP
4. SP 验证签名 + 解密 Assertion,创建本地会话

适用场景:企业内部系统、政府机构、教育机构

4. CAS(Central Authentication Service)

CAS 是 Yale 大学发起的开源 SSO 协议,基于 Ticket 模型,是最经典的 SSO 实现之一。

  • TGT(Ticket Granting Ticket):IdP 侧的全局会话票据
  • ST(Service Ticket):用于访问某个具体服务的临时票据
  • PGT(Proxy Granting Ticket):用于代理场景

四、Go 语言实现简易 SSO Demo

下面我们用 Go 实现一个基于 Token(JWT)的迷你 SSO 系统。包含:

  • auth-center:身份认证中心(IdP)
  • app-a:服务提供者 A
  • app-b:服务提供者 B

4.1 认证中心(IdP)

package main

import (
    "encoding/json"
    "fmt"
    "net/http"
    "time"

    "github.com/golang-jwt/jwt/v5"
)

var (
    // 模拟用户数据库
    users = map[string]string{
        "admin": "password123",
    }

    // 共享密钥,实际生产环境应使用 RSA/ECDSA 非对称密钥
    secretKey = []byte("sso-secret-key-2024")

    // 全局会话存储:token -> 用户名
    globalSessions = map[string]string{}
)

type Claims struct {
    Username string `json:"username"`
    jwt.RegisteredClaims
}

// 生成 JWT Token
func generateToken(username string) (string, error) {
    claims := Claims{
        Username: username,
        RegisteredClaims: jwt.RegisteredClaims{
            ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
            IssuedAt:  jwt.NewNumericDate(time.Now()),
            Issuer:    "auth-center",
        },
    }
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    return token.SignedString(secretKey)
}

// 验证 JWT Token
func validateToken(tokenString string) (*Claims, error) {
    token, err := jwt.ParseWithClaims(tokenString, &Claims{},
        func(token *jwt.Token) (interface{}, error) {
            if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
                return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
            }
            return secretKey, nil
        })
    if err != nil {
        return nil, err
    }
    claims, ok := token.Claims.(*Claims)
    if !ok || !token.Valid {
        return nil, fmt.Errorf("invalid token")
    }
    return claims, nil
}

// /login 登录页面
func loginHandler(w http.ResponseWriter, r *http.Request) {
    if r.Method == http.MethodGet {
        // 显示登录表单
        w.Header().Set("Content-Type", "text/html; charset=utf-8")
        fmt.Fprint(w, `
            <h2>SSO Auth Center - 登录</h2>
            <form method="post">
                <div><label>用户名:<input type="text" name="username" /></label></div>
                <div><label>密码:<input type="password" name="password" /></label></div>
                <div><button type="submit">登录</button></div>
            </form>
        `)
        return
    }

    // 处理登录 POST
    username := r.FormValue("username")
    password := r.FormValue("password")
    redirect := r.URL.Query().Get("redirect")

    if pass, ok := users[username]; !ok || pass != password {
        http.Error(w, "用户名或密码错误", http.StatusUnauthorized)
        return
    }

    // 生成全局 Token
    token, _ := generateToken(username)
    globalSessions[token] = username

    // 将 Token 写入 Cookie
    http.SetCookie(w, &http.Cookie{
        Name:     "sso_token",
        Value:    token,
        Path:     "/",
        HttpOnly: true,
        MaxAge:   3600,
    })

    // 重定向回来源应用
    if redirect != "" {
        http.Redirect(w, r, redirect+"?token="+token, http.StatusFound)
        return
    }
    fmt.Fprintf(w, "登录成功!Token: %s", token)
}

// /validate 验证 Token
func validateHandler(w http.ResponseWriter, r *http.Request) {
    token := r.URL.Query().Get("token")
    if token == "" {
        http.Error(w, "missing token", http.StatusBadRequest)
        return
    }

    claims, err := validateToken(token)
    if err != nil {
        http.Error(w, "invalid token: "+err.Error(), http.StatusUnauthorized)
        return
    }

    json.NewEncoder(w).Encode(map[string]interface{}{
        "valid":    true,
        "username": claims.Username,
    })
}

// /check 检查用户是否已登录(SSO 状态检查)
func checkHandler(w http.ResponseWriter, r *http.Request) {
    cookie, err := r.Cookie("sso_token")
    if err != nil {
        json.NewEncoder(w).Encode(map[string]bool{"logged_in": false})
        return
    }

    _, ok := globalSessions[cookie.Value]
    json.NewEncoder(w).Encode(map[string]bool{"logged_in": ok})
}

func main() {
    http.HandleFunc("/login", loginHandler)
    http.HandleFunc("/validate", validateHandler)
    http.HandleFunc("/check", checkHandler)

    fmt.Println("Auth Center 启动于 :8080")
    http.ListenAndServe(":8080", nil)
}

4.2 服务提供者 A(App-A)

package main

import (
    "encoding/json"
    "fmt"
    "io"
    "net/http"
)

const authCenter = "http://localhost:8080"

var localSessions = map[string]string{} // token -> username

func main() {
    http.HandleFunc("/", indexHandler)
    http.HandleFunc("/callback", callbackHandler)
    fmt.Println("App-A 启动于 :9001")
    http.ListenAndServe(":9001", nil)
}

func indexHandler(w http.ResponseWriter, r *http.Request) {
    // 检查本地会话
    tokenCookie, err := r.Cookie("app_a_token")
    if err != nil {
        // 未登录,重定向到 SSO 登录
        redirectURL := fmt.Sprintf("%s/login?redirect=http://localhost:9001/callback", authCenter)
        http.Redirect(w, r, redirectURL, http.StatusFound)
        return
    }

    username := localSessions[tokenCookie.Value]
    if username == "" {
        http.Redirect(w, r, fmt.Sprintf("%s/login?redirect=http://localhost:9001/callback", authCenter), http.StatusFound)
        return
    }

    w.Header().Set("Content-Type", "text/html; charset=utf-8")
    fmt.Fprintf(w, "<h2>App-A (CRM 系统)</h2><p>欢迎, %s!<a href='/logout'>登出</a></p>", username)
}

func callbackHandler(w http.ResponseWriter, r *http.Request) {
    token := r.URL.Query().Get("token")
    if token == "" {
        http.Error(w, "missing token", http.StatusBadRequest)
        return
    }

    // 向 Auth Center 验证 Token
    resp, err := http.Get(fmt.Sprintf("%s/validate?token=%s", authCenter, token))
    if err != nil {
        http.Error(w, "auth center error", http.StatusInternalServerError)
        return
    }
    defer resp.Body.Close()

    var result struct {
        Valid    bool   `json:"valid"`
        Username string `json:"username"`
    }
    body, _ := io.ReadAll(resp.Body)
    json.Unmarshal(body, &result)

    if !result.Valid {
        http.Error(w, "token invalid", http.StatusUnauthorized)
        return
    }

    // 创建本地会话
    sessionToken := "session_" + token
    localSessions[sessionToken] = result.Username
    http.SetCookie(w, &http.Cookie{
        Name:     "app_a_token",
        Value:    sessionToken,
        Path:     "/",
        HttpOnly: true,
    })

    http.Redirect(w, r, "/", http.StatusFound)
}

4.3 运行验证

# 启动认证中心
go run auth-center/main.go

# 新开终端,启动 App-A
go run app-a/main.go

# 新开终端,启动 App-B(代码结构类似 App-A,监听 :9002)
go run app-b/main.go

访问 http://localhost:9001,会被重定向到认证中心登录页,输入 admin / password123 登录后自动跳回 App-A。再访问 http://localhost:9002,由于已建立全局会话,直接跳入系统,无需再次登录。

五、SSO 最佳实践

1. 使用 HTTPS 和 Secure Cookie

// 生产环境 Cookie 配置
http.SetCookie(w, &http.Cookie{
    Name:     "sso_token",
    Value:    token,
    Path:     "/",
    HttpOnly: true,      // 禁止 JavaScript 读取
    Secure:   true,       // 仅 HTTPS 传输
    SameSite: http.SameSiteStrictMode, // 防止 CSRF
    MaxAge:   3600,
})

2. 使用非对称签名

生产环境不要用 HMAC 对称密钥,改用 RSA 或 ECDSA:

// 使用 RSA 私钥签名
privateKey, _ := jwt.ParseRSAPrivateKeyFromPEM(privateKeyPEM)
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
signedToken, _ := token.SignedString(privateKey)

// 各 SP 用 RSA 公钥验证,无需访问 IdP
publicKey, _ := jwt.ParseRSAPublicKeyFromPEM(publicKeyPEM)
claims, _ := jwt.ParseWithClaims(tokenString, &Claims{},
    func(token *jwt.Token) (interface{}, error) {
        return publicKey, nil
    })

3. Token 有效期与刷新

  • Access Token 有效期短(15-30 分钟)
  • Refresh Token 有效期长(7-30 天),可轮换
  • 实现 Token 黑名单机制应对泄露

4. 引入 PKCE

对于原生 App 或 SPA,使用 PKCE(Proof Key for Code Exchange)增强授权码流程的安全性。

5. 登出(SLO)

实现单点登出时,IdP 需通知所有已登录的 SP 清除会话。常见方式:

- 前端登出后调用各 SP 的登出端点

- 使用消息队列异步通知

六、SSO 的潜在风险

风险描述缓解措施
单点故障IdP 宕机导致所有系统无法登录高可用部署、本地缓存会话
会话劫持Token 泄露导致攻击者可访问所有系统短有效期 + 绑定设备指纹
CSRF 攻击恶意网站诱导用户完成认证流程State 参数 + PKCE
SSRF 风险IdP 到 SP 的回调可能被利用校验回调 URL 白名单
账号锁定放大暴力破解 IdP 导致所有系统无法使用速率限制 + 多因素认证

七、总结

SSO 的核心思想很简单——认证集中、授权分散。它通过一个统一的身份提供者(IdP)管理用户登录状态,各业务系统只负责验证用户是否已由 IdP 认证通过,不再自行管理密码。

选择 SSO 协议时:

- 新项目首选 OIDC:基于 JWT、现代、生态丰富

- 企业内部选 SAML:与 AD/LDAP 配合成熟

- 统一身份管理选 CAS:经典 Ticket 模型,适合高校或研究机构

- 第三方登录用 OAuth 2.0:Google、GitHub、微信等广泛支持

Go 语言凭借其标准库的 HTTP 能力、JWT 生态的优秀支持(golang-jwt/jwt),以及对并发连接的原生处理,非常适合构建高性能的 SSO 认证中心。



希望这篇文章能帮你理解 SSO 的核心原理,并在实际项目中落地应用。有问题欢迎讨论交流!

#go
#前端
#后端