第2章:单点登录原理与实现
什么是单点登录
定义
Single Sign-On (SSO): 用户只需登录一次,就可以访问所有相互信任的应用系统,无需重复登录。
传统登录 vs SSO
传统方式(多次登录):
用户访问多个系统,需要多次登录:
┌─────────┐ 登录1 ┌─────────┐
│ User │────────────> │ 系统A │
└─────────┘ └─────────┘
│ 登录2 ┌─────────┐
├───────────────────>│ 系统B │
│ └─────────┘
│ 登录3 ┌─────────┐
└───────────────────>│ 系统C │
└─────────┘
问题:
多次输入密码
多个账号管理
用户体验差
SSO方式(一次登录):
用户只需登录一次SSO,即可访问所有系统:
┌─────────┐ 1.访问 ┌─────────┐
│ User │───────────> │ 系统A │
└─────────┘ └─────────┘
│ │ 2.重定向
│ ↓
│ ┌─────────┐
│ 3.登录 │ SSO │
├─────────────────>│ 服务器 │
│ └─────────┘
│ 4.返回票据 │
│<───────────────────────┤
│ │
│ 5.访问系统B │
│──────────────────> ┌─────────┐
│ │ 系统B │
│ └─────────┘
│ │ 6.验证票据
│ ↓
│ ┌─────────┐
│ │ SSO │
│ │ 服务器 │
│ └─────────┘
│<──────────────────────┤ 7.验证成功
│ │
已登录所有系统
优势:
一次登录,全局有效
统一身份管理
提升用户体验
SSO核心流程
完整流程图
┌──────┐ ┌─────────┐ ┌─────────┐
│ User │ │ 应用A │ │ SSO │
└──────┘ └─────────┘ │ Server │
│ │ └─────────┘
│ 1. 访问应用A │ │
├─────────────────────────────>│ │
│ │ │
│ │ 2. 检查本地Session │
│ │ (未登录) │
│ │ │
│ │ 3. 重定向到SSO登录页 │
│<─────────────────────────────┤ │
│ Redirect: │ │
│ sso.com/login?service=appA │ │
│ │ │
│ 4. 访问SSO登录页 │
├────────────────────────────────────────────────────────────>│
│ │ │
│ │ 5. 检查SSO Session
│ │ (未登录) │
│ │ │
│ │ 6. 显示登录页面 │
│<────────────────────────────────────────────────────────────┤
│ │ │
│ 7. 输入用户名密码 │
├────────────────────────────────────────────────────────────>│
│ │ │
│ │ 8. 验证成功 │
│ │ 9. 创建SSO Session
│ │ 10. 生成票据(Ticket)
│ │ │
│ 11. 重定向回应用A,携带票据 │
│<────────────────────────────────────────────────────────────┤
│ Redirect: │ │
│ appA.com/login?ticket=ST-1 │ │
│ │ │
│ 12. 访问应用A,携带票据 │ │
├─────────────────────────────>│ │
│ │ │
│ │ 13. 验证票据 │
│ ├───────────────────────────> │
│ │ │
│ │ 14. 票据有效 │
│ │<────────────────────────────┤
│ │ 15. 返回用户信息 │
│ │ │
│ │ 16. 创建本地Session │
│ │ │
│ 17. 返回受保护资源 │ │
│<─────────────────────────────┤ │
│ │ │
│ │
│ === 用户再访问应用B === │
│ │ │
│ 18. 访问应用B ┌─────────┐│
├────────────────────────────────────────────────>│ 应用B ││
│ └─────────┘│
│ │ │
│ 19. 检查本地Session │ │
│ (未登录) │ │
│ │ │
│ 20. 重定向到SSO │ │
│<──────────────────────────────────────────────────────┤ │
│ Redirect: sso.com/login?service=appB │ │
│ │ │
│ 21. 访问SSO │
├────────────────────────────────────────────────────────────>│
│ │
│ 22. 检查SSO Session│
│ (已登录) │
│ 23. 直接生成票据 │
│ │
│ 24. 重定向回应用B,携带票据 │
│<────────────────────────────────────────────────────────────┤
│ Redirect: appB.com/login?ticket=ST-2 │
│ │
│ 25. 应用B验证票据,创建本地Session │
├────────────────────────────────────────────────────────────>│
│ │
│ 26. 登录成功(无需再次输入密码) │
│<────────────────────────────────────────────────────────────┤
│ │
CAS协议
CAS简介
CAS (Central Authentication Service): Yale大学开发的开源SSO协议。
CAS核心概念
1. TGT (Ticket Granting Ticket):
- SSO服务器颁发给用户
- 存储在SSO服务器的Session中
- 用于生成ST
2. TGC (Ticket Granting Cookie):
- 存储TGT的Cookie
- 存储在用户浏览器中
- 用于标识SSO Session
3. ST (Service Ticket):
- 一次性票据
- 用于应用系统验证用户身份
- 验证后立即销毁
CAS登录流程(详细)
// 1. 用户访问应用A
GET https://app-a.com/protected-resource
// 2. 应用A检查本地Session(未登录)
// 重定向到CAS Server
HTTP/1.1 302 Found
Location: https://sso.example.com/cas/login?service=https://app-a.com/cas-callback
// 3. CAS Server检查TGC Cookie(首次访问,无TGC)
// 显示登录页面
// 4. 用户输入用户名密码
POST https://sso.example.com/cas/login
{
"username": "john",
"password": "password123",
"service": "https://app-a.com/cas-callback"
}
// 5. CAS Server验证成功
// - 生成TGT
// - 设置TGC Cookie
// - 生成ST
// - 重定向回应用A
HTTP/1.1 302 Found
Set-Cookie: TGC=TGT-1-xxxx; Path=/cas; HttpOnly; Secure
Location: https://app-a.com/cas-callback?ticket=ST-1-yyyy
// 6. 应用A收到ST
// 向CAS Server验证ST
GET https://sso.example.com/cas/serviceValidate?ticket=ST-1-yyyy&service=https://app-a.com/cas-callback
// 7. CAS Server验证ST,返回用户信息
HTTP/1.1 200 OK
Content-Type: application/xml
<cas:serviceResponse>
<cas:authenticationSuccess>
<cas:user>john</cas:user>
<cas:attributes>
<cas:email>john@example.com</cas:email>
<cas:name>John Doe</cas:name>
</cas:attributes>
</cas:authenticationSuccess>
</cas:serviceResponse>
// 8. 应用A创建本地Session
// 返回受保护资源
// 9. 用户访问应用B(已有TGC Cookie)
GET https://app-b.com/protected-resource
// 10. 应用B重定向到CAS Server
HTTP/1.1 302 Found
Location: https://sso.example.com/cas/login?service=https://app-b.com/cas-callback
// 11. CAS Server检查TGC Cookie(已存在)
// 直接生成ST,重定向回应用B(无需登录)
HTTP/1.1 302 Found
Location: https://app-b.com/cas-callback?ticket=ST-2-zzzz
// 12. 应用B验证ST,创建本地Session
// 用户无需再次登录
SAML协议
SAML简介
SAML (Security Assertion Markup Language): 基于XML的企业级SSO标准。
SAML核心概念
1. IdP (Identity Provider):
身份提供者,负责认证用户
2. SP (Service Provider):
服务提供者,业务应用系统
3. Assertion:
断言,包含用户身份信息的XML文档
SAML流程(SP-Initiated)
┌──────┐ ┌────┐ ┌────┐
│ User │ │ SP │ │IdP │
└──────┘ └────┘ └────┘
│ │ │
│ 1. 访问SP │ │
├───────────────────────>│ │
│ │ │
│ │ 2. 生成SAML Request │
│ │ │
│ 3. 重定向到IdP(携带SAMLRequest) │
│<───────────────────────┤ │
│ │ │
│ 4. 访问IdP(携带SAMLRequest) │
├──────────────────────────────────────────────>│
│ │ │
│ │ 5. 解析请求 │
│ │ 6. 验证用户 │
│ │ 7. 生成Assertion
│ │ │
│ 8. 返回SAML Response(包含Assertion) │
│<──────────────────────────────────────────────┤
│ │ │
│ 9. 提交SAML Response到SP │
├───────────────────────>│ │
│ │ │
│ │ 10. 验证Assertion │
│ │ 11. 创建Session │
│ │ │
│ 12. 返回受保护资源 │ │
│<───────────────────────┤ │
│ │ │
JWT-based SSO
原理
使用JWT作为SSO票据,实现跨域单点登录
优势:
无状态(无需服务端存储)
跨域友好
易于实现
流程:
1. 用户登录SSO,获取JWT
2. JWT包含用户信息
3. 各应用验证JWT即可
实现方案
方案1:共享JWT密钥
┌─────────┐ ┌─────────┐
│ SSO │<──共享密钥──────────>│ 应用A/B │
└─────────┘ └─────────┘
所有应用使用相同密钥验证JWT
优点:简单
缺点:密钥泄露风险
方案2:公钥/私钥
┌─────────┐ ┌─────────┐
│ SSO │──私钥签名 JWT────────>│ 应用A/B │
└─────────┘ └─────────┘
│
│ 公钥验证JWT
↓
优点:更安全
缺点:需要分发公钥
Go实现SSO
完整SSO服务器实现
sso_server.go:
package main
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"html/template"
"net/http"
"sync"
"time"
)
// TGT (Ticket Granting Ticket)
type TGT struct {
ID string
UserID int
Username string
CreatedAt time.Time
ExpiresAt time.Time
}
// ST (Service Ticket)
type ST struct {
ID string
TGTID string
Service string
UserID int
Username string
CreatedAt time.Time
Used bool
}
// SSOServer SSO服务器
type SSOServer struct {
tgts map[string]*TGT
sts map[string]*ST
mu sync.RWMutex
}
func NewSSOServer() *SSOServer {
return &SSOServer{
tgts: make(map[string]*TGT),
sts: make(map[string]*ST),
}
}
// Login 登录页面
func (s *SSOServer) LoginPage(w http.ResponseWriter, r *http.Request) {
service := r.URL.Query().Get("service")
// 检查TGC Cookie(是否已登录)
cookie, err := r.Cookie("TGC")
if err == nil {
// 已登录,直接生成ST并重定向
tgt, exists := s.getTGT(cookie.Value)
if exists {
st := s.createST(tgt, service)
http.Redirect(w, r, service+"?ticket="+st.ID, http.StatusFound)
return
}
}
// 未登录,显示登录页面
tmpl := `
<html>
<body>
<h2>SSO Login</h2>
<form method="POST" action="/sso/login">
<input type="hidden" name="service" value="{{.Service}}">
<input type="text" name="username" placeholder="Username" required><br>
<input type="password" name="password" placeholder="Password" required><br>
<button type="submit">Login</button>
</form>
</body>
</html>
`
t := template.Must(template.New("login").Parse(tmpl))
t.Execute(w, map[string]string{"Service": service})
}
// Login 处理登录
func (s *SSOServer) Login(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
username := r.FormValue("username")
password := r.FormValue("password")
service := r.FormValue("service")
// 验证用户名密码(简化示例)
if !s.authenticate(username, password) {
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
return
}
// 创建TGT
tgt := s.createTGT(1, username)
// 设置TGC Cookie
http.SetCookie(w, &http.Cookie{
Name: "TGC",
Value: tgt.ID,
Path: "/sso",
HttpOnly: true,
Secure: true,
MaxAge: 7200, // 2小时
})
// 生成ST
st := s.createST(tgt, service)
// 重定向回应用
http.Redirect(w, r, service+"?ticket="+st.ID, http.StatusFound)
}
// ValidateTicket 验证票据
func (s *SSOServer) ValidateTicket(w http.ResponseWriter, r *http.Request) {
ticket := r.URL.Query().Get("ticket")
service := r.URL.Query().Get("service")
st, exists := s.getST(ticket)
if !exists {
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]string{
"error": "Invalid ticket",
})
return
}
// 验证Service
if st.Service != service {
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]string{
"error": "Service mismatch",
})
return
}
// 验证票据是否已使用
if st.Used {
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]string{
"error": "Ticket already used",
})
return
}
// 标记为已使用
s.markSTAsUsed(ticket)
// 返回用户信息
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"user": map[string]interface{}{
"id": st.UserID,
"username": st.Username,
},
})
}
// Logout 登出
func (s *SSOServer) Logout(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("TGC")
if err == nil {
s.deleteTGT(cookie.Value)
}
// 清除Cookie
http.SetCookie(w, &http.Cookie{
Name: "TGC",
Value: "",
Path: "/sso",
MaxAge: -1,
HttpOnly: true,
Secure: true,
})
w.Write([]byte("Logged out successfully"))
}
// 创建TGT
func (s *SSOServer) createTGT(userID int, username string) *TGT {
tgtID := "TGT-" + generateID()
tgt := &TGT{
ID: tgtID,
UserID: userID,
Username: username,
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(2 * time.Hour),
}
s.mu.Lock()
s.tgts[tgtID] = tgt
s.mu.Unlock()
return tgt
}
// 创建ST
func (s *SSOServer) createST(tgt *TGT, service string) *ST {
stID := "ST-" + generateID()
st := &ST{
ID: stID,
TGTID: tgt.ID,
Service: service,
UserID: tgt.UserID,
Username: tgt.Username,
CreatedAt: time.Now(),
Used: false,
}
s.mu.Lock()
s.sts[stID] = st
s.mu.Unlock()
return st
}
// 获取TGT
func (s *SSOServer) getTGT(tgtID string) (*TGT, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
tgt, exists := s.tgts[tgtID]
if !exists || time.Now().After(tgt.ExpiresAt) {
return nil, false
}
return tgt, true
}
// 获取ST
func (s *SSOServer) getST(stID string) (*ST, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
st, exists := s.sts[stID]
return st, exists
}
// 标记ST为已使用
func (s *SSOServer) markSTAsUsed(stID string) {
s.mu.Lock()
if st, exists := s.sts[stID]; exists {
st.Used = true
}
s.mu.Unlock()
}
// 删除TGT
func (s *SSOServer) deleteTGT(tgtID string) {
s.mu.Lock()
delete(s.tgts, tgtID)
s.mu.Unlock()
}
// 生成随机ID
func generateID() string {
bytes := make([]byte, 16)
rand.Read(bytes)
return hex.EncodeToString(bytes)
}
// 验证用户(简化示例)
func (s *SSOServer) authenticate(username, password string) bool {
// 实际应该查询数据库
return username == "admin" && password == "password"
}
func main() {
sso := NewSSOServer()
http.HandleFunc("/sso/login", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
sso.LoginPage(w, r)
} else {
sso.Login(w, r)
}
})
http.HandleFunc("/sso/validate", sso.ValidateTicket)
http.HandleFunc("/sso/logout", sso.Logout)
http.ListenAndServe(":8080", nil)
}
应用端实现
app_client.go:
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
)
const (
SSOServerURL = "http://localhost:8080"
AppCallbackURL = "http://localhost:8081/callback"
)
// AppServer 应用服务器
type AppServer struct {
sessions map[string]string // sessionID -> username
}
func NewAppServer() *AppServer {
return &AppServer{
sessions: make(map[string]string),
}
}
// ProtectedResource 受保护资源
func (a *AppServer) ProtectedResource(w http.ResponseWriter, r *http.Request) {
// 检查本地Session
cookie, err := r.Cookie("sessionid")
if err != nil || a.sessions[cookie.Value] == "" {
// 未登录,重定向到SSO
loginURL := fmt.Sprintf("%s/sso/login?service=%s",
SSOServerURL,
url.QueryEscape(AppCallbackURL))
http.Redirect(w, r, loginURL, http.StatusFound)
return
}
// 已登录,返回受保护资源
username := a.sessions[cookie.Value]
fmt.Fprintf(w, "Welcome, %s! This is a protected resource.", username)
}
// Callback SSO回调
func (a *AppServer) Callback(w http.ResponseWriter, r *http.Request) {
ticket := r.URL.Query().Get("ticket")
if ticket == "" {
http.Error(w, "Missing ticket", http.StatusBadRequest)
return
}
// 验证票据
validateURL := fmt.Sprintf("%s/sso/validate?ticket=%s&service=%s",
SSOServerURL,
ticket,
url.QueryEscape(AppCallbackURL))
resp, err := http.Get(validateURL)
if err != nil {
http.Error(w, "Failed to validate ticket", http.StatusInternalServerError)
return
}
defer resp.Body.Close()
var result struct {
Success bool `json:"success"`
User struct {
ID int `json:"id"`
Username string `json:"username"`
} `json:"user"`
}
json.NewDecoder(resp.Body).Decode(&result)
if !result.Success {
http.Error(w, "Invalid ticket", http.StatusUnauthorized)
return
}
// 创建本地Session
sessionID := generateSessionID()
a.sessions[sessionID] = result.User.Username
http.SetCookie(w, &http.Cookie{
Name: "sessionid",
Value: sessionID,
Path: "/",
HttpOnly: true,
MaxAge: 7200,
})
// 重定向到受保护资源
http.Redirect(w, r, "/protected", http.StatusFound)
}
func generateSessionID() string {
// 简化示例
return fmt.Sprintf("sess_%d", time.Now().Unix())
}
func main() {
app := NewAppServer()
http.HandleFunc("/protected", app.ProtectedResource)
http.HandleFunc("/callback", app.Callback)
fmt.Println("App running on :8081")
http.ListenAndServe(":8081", nil)
}
面试问答
什么是单点登录?SSO解决了什么问题?
答案:
定义:单点登录(SSO)允许用户只需登录一次,即可访问多个相互信任的应用系统。
解决的问题:
1. 多次登录:用户无需为每个系统重复登录
2. 密码管理:减少用户需要记住的密码数量
3. 用户体验:提升便捷性
4. 安全管理:统一身份管理,易于审计