Files
Qiniu/technical/sso/sso-cas.md
T

223 lines
9.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [sso, cas, auth]
create time: 2026-07-13 10:03
---
# CAS 协议
## 概述
2000 年,耶鲁大学面临一个问题:校园里有图书馆系统、邮件系统、选课系统、成绩查询……每个系统都要单独登录。教授们抱怨「我记不住这么多密码」。
于是耶鲁开发了 **CAS(Central Authentication Service,中央认证服务)**——一个极简的 SSO 协议。核心思路只有一句话:**用户在统一的登录页登录,拿到一张「一次性票据」,拿着票据去各个系统换通行证**。
> [!info] 类比:游乐园的手环
> 你在游乐园门口买票(登录),工作人员给你戴上一条手环(TGT)。之后你去每个游乐设施(SP),出示手环,工作人员给你一张一次性小票(ST),验证通过后就能玩。手环是你的**登录凭证**,小票是每个设施的**访问凭证**。
>
> CAS 协议就是这个流程的标准化描述。
**当前版本**:CAS Protocol 3.0(2016 年发布,增加了属性释放能力)。
## 核心流程
```mermaid
sequenceDiagram
participant U as 用户 (浏览器)
participant App as 业务应用 (SP)
participant CAS as CAS Server (IdP)
U->>App: 1. 访问受保护页面
App->>U: 2. 「你还没登录」<br/>302 重定向到 CAS Server
Note right of App: URL: /login?service=https://app.example.com
U->>CAS: 3. 浏览器跳到 CAS 登录页
CAS->>U: 4. 展示登录表单
U->>CAS: 5. 输入账号密码
CAS->>CAS: 6. 验证凭证,生成 Service Ticket (ST)
CAS->>U: 7. 302 跳回业务应用,URL 带上 ST
Note left of CAS: Location: https://app.example.com?ticket=ST-12345-xxxxx
U->>App: 8. 浏览器跳回应用,ST 在 URL 里
App->>CAS: 9. 应用后端拿着 ST 去 CAS 验证
Note right of App: GET /p3/serviceValidate?ticket=ST-xxx&service=...
CAS-->>App: 10. 返回用户信息(XML 格式)
App-->>U: 11. 验证通过,建立本地 Session,登录成功
```
**关键设计**:
- 步骤 7 的 `service` 参数告诉 CAS「验证完后跳回哪里」
- 步骤 9 是**后端直连** CAS,不经过浏览器——ST 不会暴露给用户
- ST 是**一次性的**,验证一次就失效,有效期通常 < 10 秒
## Ticket 类型
CAS 用 Ticket(票据)机制管理认证状态。有两种你必须理解:
### 核心 Ticket
| Ticket | 全称 | 一句话解释 | 生命周期 |
|--------|------|-----------|----------|
| **TGT** | Ticket Granting Ticket | **登录凭证**。用户在 CAS 登录成功后获得,相当于「游乐园手环」 | 用户会话级,存在 CAS Server 端 |
| **ST** | Service Ticket | **访问凭证**。每个 SP 访问时临时生成,相当于「游乐设施的小票」 | 一次性,验证即毁,有效期 < 10 秒 |
### 高级 Ticket(代理场景,大多数情况用不到)
| Ticket | 说明 |
|--------|------|
| **PT** | Proxy Ticket,SP 代替用户去访问另一个 SP 时使用 |
| **PGT** | Proxy Granting Ticket,用于获取 PT 的凭证 |
| **PGTIOU** | PGT 和 ST 之间的关联标识 |
> [!tip] 不需要全部理解
> 如果你的场景只是「用户登录 → 访问业务系统」,**只需要理解 TGT 和 ST**。Proxy 系列 Ticket 是为链式代理认证设计的,90% 的接入方用不到。
### Ticket 流转图
```mermaid
graph TD
A[用户在 CAS Server 登录] -->|成功| B[CAS Server 颁发 TGT]
B -->|TGT 以 Cookie 形式<br/>存在 CAS 域名下| C[用户浏览器持有 TGT Cookie]
C -->|用户访问 SP 1| D[CAS 生成 ST-1]
C -->|用户访问 SP 2| E[CAS 生成 ST-2]
D -->|SP 1 后端验证 ST-1| F[SP 1 建立本地 Session]
E -->|SP 2 后端验证 ST-2| G[SP 2 建立本地 Session]
```
**TGT vs ST 的关系**:TGT 是「总的登录状态」(在 CAS Server 端),ST 是「给某个 SP 的一次性凭证」。用户登录一次获得一个 TGT,之后访问每个 SP 都会从 TGT 派生出一个新的 ST。
## CAS 2.0 vs 3.0
| 版本 | 区别 |
|------|------|
| CAS 2.0 | `/serviceValidate` 端点,只返回用户名 |
| CAS 3.0 | `/p3/serviceValidate` 端点,除了用户名还返回用户属性(邮箱、角色等) |
**CAS 3.0 响应示例:**
```xml
<cas:serviceResponse>
<cas:authenticationSuccess>
<cas:user>zhangsan</cas:user> <!-- 用户名 -->
<cas:attributes>
<cas:email>zhangsan@example.com</cas:email> <!-- 邮箱 -->
<cas:displayName>张三</cas:displayName> <!-- 显示名 -->
<cas:department>engineering</cas:department> <!-- 部门 -->
</cas:attributes>
</cas:authenticationSuccess>
</cas:serviceResponse>
```
> 接入时优先使用 CAS 3.0(`/p3/serviceValidate`),能拿到更多用户信息。
## Go 接入示例
CAS 接入的核心逻辑:拦截请求 → 重定向登录 → 验证 Ticket → 建立 Session。
```go
const (
casServerURL = "https://cas.example.com"
serviceURL = "https://app.example.com"
)
// CASMiddleware 拦截所有请求,未登录则重定向到 CAS
func CASMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 1. 已经登录过(本地 Session 存在)→ 直接放行
session, _ := store.Get(r, "session")
if user, ok := session.Values["user"].(string); ok && user != "" {
next.ServeHTTP(w, r)
return
}
// 2. 检查 URL 中是否有 Ticket
ticket := r.URL.Query().Get("ticket")
if ticket == "" {
// 没有 Ticket → 重定向到 CAS 登录页
// service 参数告诉 CAS 登录完成后跳回哪里
loginURL := fmt.Sprintf("%s/login?service=%s",
casServerURL,
url.QueryEscape(serviceURL+r.URL.Path),
)
http.Redirect(w, r, loginURL, http.StatusFound)
return
}
// 3. 有 Ticket → 后端去 CAS 验证(这一步是后端直连,不经过浏览器)
validateURL := fmt.Sprintf(
"%s/p3/serviceValidate?ticket=%s&service=%s",
casServerURL,
ticket,
url.QueryEscape(serviceURL+r.URL.Path),
)
resp, err := http.Get(validateURL)
if err != nil {
http.Error(w, "CAS validation failed", http.StatusBadGateway)
return
}
defer resp.Body.Close()
// 4. 解析 CAS 返回的 XML,提取用户名
body, _ := io.ReadAll(resp.Body)
// 简化的 XML 解析(生产环境建议用 encoding/xml)
var result struct {
AuthenticationSuccess struct {
User string `xml:"user"`
} `xml:"authenticationSuccess"`
}
if err := xml.Unmarshal(body, &result); err != nil ||
result.AuthenticationSuccess.User == "" {
http.Error(w, "CAS authentication failed", http.StatusUnauthorized)
return
}
// 5. 建立本地 Session(之后就不用每次都去 CAS 验证了)
session.Values["user"] = result.AuthenticationSuccess.User
session.Save(r, w)
// 6. 重定向到去掉 ticket 参数的原始 URL
// 这样用户刷新页面不会重复验证 Ticket(Ticket 已经失效了)
cleanURL := r.URL.Path
if r.URL.RawQuery != "" {
// 保留非 ticket 的查询参数
q := r.URL.Query()
q.Del("ticket")
if encoded := q.Encode(); encoded != "" {
cleanURL += "?" + encoded
}
}
http.Redirect(w, r, cleanURL, http.StatusFound)
})
}
```
## 常见陷阱与最佳实践
### Ticket 不能重复验证
Service Ticket 验证一次就失效了。如果用户点完登录后按了 F5 刷新页面,URL 里的 ST 已经用过了,再去验证会失败。
**解决**:步骤 6 中重定向到去掉 ticket 参数的 URL。用户刷新时 URL 里没有 ticket,走的是本地 Session(步骤 1),不会重复验证。
### service 参数必须严格匹配
CAS Server 会校验 `service` 参数是否在预注册的白名单中。不要把用户可控的内容拼到 `service` 参数里。
### 必须用 HTTPS
CAS 的 Ticket 是通过 URL 传递的(`?ticket=ST-xxx`)。如果用 HTTP,网络上的任何人都能看到 Ticket 并冒充用户。**CAS 必须全程 HTTPS。**
### 登出的局限性
CAS 的 `/logout` 端点会清除 TGT(CAS Server 端的登录态),但它没法直接清除各个 SP 的本地 Session。
CAS 通过两种方式通知 SP:
- **Front-channel(前台通知)**:浏览器跳转到各个 SP 的登出 URL(通过 iframe 或重定向)
- **Back-channel(后台通知)**:CAS Server 直接调用 SP 的登出接口(服务器对服务器)
实际部署中,很多 SP 不实现登出回调。**务实的做法**:先清除本地 Session,CAS 端的 TGT 也清除,用户下次访问任何 SP 都会重新走登录流程。
### TGT 过期后的体验
TGT 通常有较长的有效期(几小时到几天,取决于配置)。TGT 过期后,用户访问 SP 时会被重定向到 CAS 登录页重新输入密码。如果 CAS Server 配置了「记住我」功能,可能只需要重新点击确认而不需要输入密码。
> [!tip] 现代 CAS Server 的进化
> Apereo CAS Server 6.x+ 已经不只是一个 CAS 协议服务器了。它同时支持 **CAS / OIDC / SAML** 协议,可以作为统一身份网关——新系统用 OIDC 接入,老系统用 CAS 接入,一套 CAS Server 全搞定。如果你需要开源自建 SSO 平台,Apereo CAS 值得考虑。