vault backup: 2026-07-13 11:11:37
This commit is contained in:
+138
-95
@@ -7,149 +7,182 @@ create time: 2026-07-13 10:03
|
||||
|
||||
## 概述
|
||||
|
||||
CAS(Central Authentication Service)是由耶鲁大学于 2000 年开发的 SSO 协议,后由 Apereo 基金会维护。它是最早的 SSO 标准之一,设计简洁,在高校和企业内部门户中仍有广泛应用。当前版本为 CAS 3.0(CAS Protocol Specification)。
|
||||
2000 年,耶鲁大学面临一个问题:校园里有图书馆系统、邮件系统、选课系统、成绩查询……每个系统都要单独登录。教授们抱怨「我记不住这么多密码」。
|
||||
|
||||
> [!info] CAS 的定位
|
||||
> 相比 SAML 的重量级 XML 和 OIDC 的 JWT 生态,CAS 的核心优势是**简单**。整个协议可以用几段 HTTP 请求描述清楚,实现成本极低。如果你只需要企业内部几个 Web 应用的 SSO,CAS 是最省事的选择。
|
||||
于是耶鲁开发了 **CAS(Central Authentication Service,中央认证服务)**——一个极简的 SSO 协议。核心思路只有一句话:**用户在统一的登录页登录,拿到一张「一次性票据」,拿着票据去各个系统换通行证**。
|
||||
|
||||
> [!info] 类比:游乐园的手环
|
||||
> 你在游乐园门口买票(登录),工作人员给你戴上一条手环(TGT)。之后你去每个游乐设施(SP),出示手环,工作人员给你一张一次性小票(ST),验证通过后就能玩。手环是你的**登录凭证**,小票是每个设施的**访问凭证**。
|
||||
>
|
||||
> CAS 协议就是这个流程的标准化描述。
|
||||
|
||||
**当前版本**:CAS Protocol 3.0(2016 年发布,增加了属性释放能力)。
|
||||
|
||||
## 核心流程
|
||||
|
||||
CAS 的核心思路是 **Ticket 机制**:用户在 CAS Server 登录后拿到一个一次性的 Service Ticket,业务应用用这个 Ticket 去 CAS Server 换取用户身份。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户 (浏览器)
|
||||
participant App as 业务应用 (CAS Client)
|
||||
participant CAS as CAS Server
|
||||
participant App as 业务应用 (SP)
|
||||
participant CAS as CAS Server (IdP)
|
||||
|
||||
U->>App: 1. 访问受保护页面
|
||||
App->>U: 302 → CAS Server
|
||||
Note right of App: service=https://app.example.com/callback
|
||||
U->>CAS: 2. 重定向到 CAS 登录页
|
||||
CAS->>U: 3. 展示登录表单
|
||||
U->>CAS: 4. 提交凭证
|
||||
CAS->>CAS: 5. 验证凭证
|
||||
CAS->>U: 302 → service URL + Ticket
|
||||
Note left of CAS: ?ticket=ST-12345-xxxxx
|
||||
U->>App: 6. 携带 Ticket 回到应用
|
||||
App->>CAS: 7. 后端验证 Ticket
|
||||
Note right of App: GET /serviceValidate?ticket=ST-xxx&service=...
|
||||
CAS-->>App: 8. 返回用户信息 (XML)
|
||||
App-->>U: 9. 建立本地 Session,登录成功
|
||||
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,各有不同用途:
|
||||
CAS 用 Ticket(票据)机制管理认证状态。有两种你必须理解:
|
||||
|
||||
| Ticket | 前缀 | 生命周期 | 用途 |
|
||||
|--------|------|----------|------|
|
||||
| **TGT** | `TGT-` | 用户会话级 | CAS Server 的登录凭证,存在 Server 端 |
|
||||
| **ST** | `ST-` | 一次性,< 10s | SP 验证用户身份用,用后即毁 |
|
||||
| **PT** | `PT-` | 一次性 | Proxy Ticket,代理场景用 |
|
||||
| **PGT** | `PGT-` | 较长 | Proxy Granting Ticket |
|
||||
| **PGTIOU** | `PGTIOU-` | 短 | PGT 和 ST 的关联标识 |
|
||||
### 核心 Ticket
|
||||
|
||||
> [!tip] 简化理解
|
||||
> 对于 90% 的场景,你只需要关心 **TGT**(用户在 CAS Server 的登录态)和 **ST**(一次性验证凭证)。其他 Ticket 是为代理认证设计的,大多数接入方用不到。
|
||||
| Ticket | 全称 | 一句话解释 | 生命周期 |
|
||||
|--------|------|-----------|----------|
|
||||
| **TGT** | Ticket Granting Ticket | **登录凭证**。用户在 CAS 登录成功后获得,相当于「游乐园手环」 | 用户会话级,存在 CAS Server 端 |
|
||||
| **ST** | Service Ticket | **访问凭证**。每个 SP 访问时临时生成,相当于「游乐设施的小票」 | 一次性,验证即毁,有效期 < 10 秒 |
|
||||
|
||||
### Ticket 流转关系
|
||||
### 高级 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[用户登录] -->|成功| B[CAS Server 颁发 TGT]
|
||||
B -->|写入 Cookie| C[TGT 存在 CAS Server]
|
||||
C -->|SP 请求| D[生成 ST]
|
||||
D -->|一次性验证| E[SP 后端验证 ST]
|
||||
E -->|返回用户信息| F[SP 建立本地 Session]
|
||||
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 协议定义的核心端点:
|
||||
## CAS 2.0 vs 3.0
|
||||
|
||||
| 端点 | 说明 |
|
||||
| 版本 | 区别 |
|
||||
|------|------|
|
||||
| `/login` | 登录端点,支持 `service` 参数 |
|
||||
| `/logout` | 登出端点,支持 `service` 参数回跳 |
|
||||
| `/serviceValidate` | ST 验证端点(CAS 2.0) |
|
||||
| `/p3/serviceValidate` | ST 验证端点(CAS 3.0,返回更多用户属性) |
|
||||
| `/proxyValidate` | PT 验证端点 |
|
||||
| `/proxy` | 获取 PGT |
|
||||
| CAS 2.0 | `/serviceValidate` 端点,只返回用户名 |
|
||||
| CAS 3.0 | `/p3/serviceValidate` 端点,除了用户名还返回用户属性(邮箱、角色等) |
|
||||
|
||||
### Ticket 验证响应示例
|
||||
**CAS 3.0 响应示例:**
|
||||
|
||||
**CAS 2.0:**
|
||||
```xml
|
||||
<cas:serviceResponse>
|
||||
<cas:authenticationSuccess>
|
||||
<cas:user>zhangsan</cas:user>
|
||||
</cas:authenticationSuccess>
|
||||
</cas:serviceResponse>
|
||||
```
|
||||
|
||||
**CAS 3.0(支持属性释放):**
|
||||
```xml
|
||||
<cas:serviceResponse>
|
||||
<cas:authenticationSuccess>
|
||||
<cas:user>zhangsan</cas:user>
|
||||
<cas:user>zhangsan</cas:user> <!-- 用户名 -->
|
||||
<cas:attributes>
|
||||
<cas:email>zhangsan@example.com</cas:email>
|
||||
<cas:displayName>张三</cas:displayName>
|
||||
<cas:department>engineering</cas:department>
|
||||
<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:
|
||||
CAS 接入的核心逻辑:拦截请求 → 重定向登录 → 验证 Ticket → 建立 Session。
|
||||
|
||||
```go
|
||||
// CAS Client 核心逻辑
|
||||
func CASServerURL = "https://cas.example.com"
|
||||
func ServiceURL = "https://app.example.com"
|
||||
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
|
||||
if session, _ := store.Get(r, "session"); session.Values["user"] != nil {
|
||||
// 1. 已经登录过(本地 Session 存在)→ 直接放行
|
||||
session, _ := store.Get(r, "session")
|
||||
if user, ok := session.Values["user"].(string); ok && user != "" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 检查 URL 中的 Ticket
|
||||
// 2. 检查 URL 中是否有 Ticket
|
||||
ticket := r.URL.Query().Get("ticket")
|
||||
if ticket == "" {
|
||||
// 3. 没有 Ticket → 重定向到 CAS
|
||||
loginURL := CASServerURL + "/login?service=" + url.QueryEscape(ServiceURL+r.URL.Path)
|
||||
// 没有 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
|
||||
}
|
||||
|
||||
// 4. 后端验证 Ticket
|
||||
// 3. 有 Ticket → 后端去 CAS 验证(这一步是后端直连,不经过浏览器)
|
||||
validateURL := fmt.Sprintf(
|
||||
"%s/p3/serviceValidate?ticket=%s&service=%s",
|
||||
CASServerURL, ticket, url.QueryEscape(ServiceURL),
|
||||
casServerURL,
|
||||
ticket,
|
||||
url.QueryEscape(serviceURL+r.URL.Path),
|
||||
)
|
||||
resp, err := http.Get(validateURL)
|
||||
if err != nil {
|
||||
http.Error(w, "CAS validation failed", 500)
|
||||
http.Error(w, "CAS validation failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 5. 解析 XML 响应,提取用户名
|
||||
// ... 解析 logic ...
|
||||
// 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
|
||||
}
|
||||
|
||||
// 6. 建立本地 Session
|
||||
session, _ := store.Get(r, "session")
|
||||
session.Values["user"] = parsedUser
|
||||
// 5. 建立本地 Session(之后就不用每次都去 CAS 验证了)
|
||||
session.Values["user"] = result.AuthenticationSuccess.User
|
||||
session.Save(r, w)
|
||||
|
||||
// 7. 重定向去掉 Ticket 参数
|
||||
cleanURL := strings.Split(r.URL.String(), "?")[0]
|
||||
// 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)
|
||||
})
|
||||
}
|
||||
@@ -157,23 +190,33 @@ func CASMiddleware(next http.Handler) http.Handler {
|
||||
|
||||
## 常见陷阱与最佳实践
|
||||
|
||||
**Ticket 不能重复验证**
|
||||
- ST 验证一次后即失效,不要缓存 Ticket 验证结果
|
||||
- 用户刷新页面时 Ticket 已经失效,应该走 Session 而不是重新验证
|
||||
### Ticket 不能重复验证
|
||||
|
||||
**service 参数必须严格匹配**
|
||||
- CAS Server 会校验 service 参数是否在注册列表中
|
||||
- 不要拼接用户可控的内容到 service 参数
|
||||
Service Ticket 验证一次就失效了。如果用户点完登录后按了 F5 刷新页面,URL 里的 ST 已经用过了,再去验证会失败。
|
||||
|
||||
**登出的局限性**
|
||||
- CAS 的 `/logout` 只能清除 TGT(CAS Server 端的登录态)
|
||||
- 各 SP 的本地 Session 需要各自处理,CAS 通过回调通知 SP(Back-channel 或 Front-channel)
|
||||
- 实际部署中,很多 SP 不实现登出回调,导致用户以为登出了但 SP Session 仍有效
|
||||
**解决**:步骤 6 中重定向到去掉 ticket 参数的 URL。用户刷新时 URL 里没有 ticket,走的是本地 Session(步骤 1),不会重复验证。
|
||||
|
||||
**CAS vs OIDC 的选择**
|
||||
- 如果你的系统只需要内网 Web 应用 SSO,CAS 足够
|
||||
- 如果需要支持移动端、SPA、第三方集成,直接上 OIDC
|
||||
- 很多现代 CAS Server(如 Apereo CAS 6.x)同时支持 CAS 协议和 OIDC/SAML
|
||||
### service 参数必须严格匹配
|
||||
|
||||
> [!tip] Apereo CAS Server 的现代化
|
||||
> Apereo CAS Server 6.x+ 已经不仅仅是 CAS 协议服务器了。它同时支持 CAS / OIDC / SAML / REST API,可以作为统一身份网关使用。如果你需要一个开源自建的 SSO 平台,值得考虑。
|
||||
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 值得考虑。
|
||||
|
||||
Reference in New Issue
Block a user