
深入理解单点登录(SSO):从原理到 Go 语言实战
一、什么是单点登录
单点登录(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) |
+-----------+ +-----------+
- User(用户):通过浏览器访问各应用
- Service Provider(SP,服务提供者):实际提供业务功能的应用,如 CRM、Wiki
- 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 的核心原理,并在实际项目中落地应用。有问题欢迎讨论交流!



