[Auto CQUPT Plan] CAS 统一认证登录逆向(补档)
1. 背景
🐛邮 里面几乎所有服务的认证几乎都依赖 https://ids.cqupt.edu.cn ,所以只要拿下这个认证的流程,那我爬 🐛邮 里的服务处理认证时不是轻松又优雅。本文整理了 CAS 认证的整个流程,以及相关的API调用链,方便开发自动化脚本。
免责声明:
1. 本文档仅供学习研究与接口分析使用,请勿用于非法用途,否则后果自负。
2. 平台接口、参数或风控策略可能随时变更,文档不保证长期有效。
2. 鉴权模型
2.1 角色一览
| 角色 | 说明 | 本例中 |
|---|---|---|
| CAS Server (IDS) | 统一认证中心,负责验证密码、发放票据 | https://ids.cqupt.edu.cn |
| CAS Client(业务系统) | 你想访问的目标服务 | 如教务系统 http://jwzx.xxx |
| User / Agent | 我们写的代码 / 浏览器 | Go net/http 程序 |
| service | 告诉 IDS “登录完要回哪” | 由调用方传入 |
2.2 请求头约定
| Header | 说明 |
|---|---|
User-Agent | 伪装浏览器,避免被简单反爬拦截 |
Content-Type | application/x-www-form-urlencoded(表单编码) |
Cookie | 手动维护,每轮响应的 Set-Cookie 需累积并回传 |
Referer / Origin | 模拟真实浏览器来源 |
2.3 关键约定
- 关闭自动重定向:
net/http默认跟随 302 会吞掉Location头,而 CAS 的ticket就在Location里。必须设置CheckRedirect返回http.ErrUseLastResponse。 - 手动维护 Cookie:浏览器自动完成的
Set-Cookie -> Cookie循环,代码里需要自己解析、累积、拼接。 - 密码不传明文:表单里的
password字段是 AES-CBC 加密后的密文。
2.4 接口一览
| # | 步骤 | Method | 路径 | 用途 |
|---|---|---|---|---|
| 1 | 获取会话 Cookie | GET | /authserver/login?service={service} | 拿 JSESSIONID |
| 2 | 获取登录页参数 | GET | 同上(带 Cookie) | 抠 execution / pwdEncryptSalt / formAction |
| 3 | 查询是否需要验证码 | GET | /authserver/checkNeedCaptcha.htl?username={username} | {"isNeed": true/false} |
| 4 | 拉取验证码图片 | GET | /authserver/getCaptcha.htl?{ts} | 返回 JPEG 字节流(可选) |
| 5 | 密码加密 | 本地 | - | AES-CBC + Base64 + URLEncode |
| 6 | 提交登录表单 | POST | /authserver/login | 302 -> Location 含 ticket=ST-xxx |
| 7 | 踢出二次确认(异常) | POST | /authserver/login | _eventId=continue(仅会话冲突时) |
3. 接口详细文档
3.1 获取会话 Cookie(接口 1)
用途:开启 CAS 会话,拿到服务端下发的 JSESSIONID 等 Cookie,后续所有请求必须携带。
GET https://ids.example.edu.cn/authserver/login?service={urlEncode(service)}- 无请求体。
- 响应:
Set-Cookie: JSESSIONID=xxx; Path=/; HttpOnly(可能还有Router等负载均衡 Cookie)。
Go 示例(来自 client/client.go):
func (r *HttpSpyderCore) GetGlobalCookie() error { finalURL := fmt.Sprintf("%s?service=%s", r.baseUrl, r.targetService)
req, err := http.NewRequest("GET", finalURL, nil) if err != nil { return err } for key, val := range r.headers { req.Header.Set(key, val) }
resp, err := r.client.Do(req) if err != nil { return err }
r.mergeCookiesFromResponse(resp) _ = resp.Body.Close() return nil}3.2 获取登录页参数(接口 2)
用途:复用上一轮的 Cookie 再次 GET 登录页,从 HTML 中提取隐藏域参数。
GET https://ids.example.edu.cn/authserver/login?service={urlEncode(service)}Cookie: {接口1累积的Cookie}登录页 HTML 中包含:
<input id="execution" name="execution" value="e1s2-2810c..." type="hidden"/><input id="pwdEncryptSalt" name="pwdEncryptSalt" value="2c8b..."><form id="pwdFromId" action="/authserver/login" ...>需要用正则或 HTML 解析器提取:
| 参数 | 说明 |
|---|---|
execution | CAS 一次性流程令牌,POST 登录时必须带上,且只能用一次 |
pwdEncryptSalt | 加密密码用的盐(32 位 hex),作为 AES key |
formAction | 表单真正要 POST 到的地址(一般就是 /authserver/login) |
Go 示例(来自 client/client.go + extractor/extractor.go):
func (r *HttpSpyderCore) GetBasicData() (*BasicData, error) { data := &BasicData{} finalURL := fmt.Sprintf("%s?service=%s", r.baseUrl, r.targetService)
req, err := http.NewRequest("GET", finalURL, nil) if err != nil { return nil, err } for key, val := range r.headers { req.Header.Set(key, val) }
resp, err := r.client.Do(req) if err != nil { return nil, err } r.mergeCookiesFromResponse(resp)
bodyBytes, err := io.ReadAll(resp.Body) if err != nil { return nil, err }
data.Execution = extractor.ExtractInputValue(bodyBytes, `id="execution"`, `name="execution"`) data.PwdEncryptSalt = extractor.ExtractInputValue(bodyBytes, `id="pwdEncryptSalt"`) data.FormAction = extractor.ExtractAnyFormAction(bodyBytes, "pwdFromId", "casLoginForm")
if data.FormAction != "" { r.postURL = r.resolveLoginURL(data.FormAction) } _ = resp.Body.Close() return data, nil}
ExtractInputValue用正则先匹配<input ... 属性 ...>,再从中取value="..."。ExtractAnyFormAction按pwdFromId->casLoginForm的顺序匹配<form>的action属性。
正则实现(来自 extractor/extractor.go):
func ExtractInputValue(html []byte, attrs ...string) string { htmlStr := string(html)
inputPattern := `<input[^>]+` for _, attr := range attrs { inputPattern += `[^>]*` + attr + `[^>]*` } inputPattern += `[^>]*>`
re := regexp.MustCompile(`(?i)` + inputPattern) match := re.FindString(htmlStr) if match == "" { return "" }
valueRe := regexp.MustCompile(`(?i)value\s*=\s*["']([^"']*)["']`) valueMatch := valueRe.FindStringSubmatch(match) if len(valueMatch) > 1 { return valueMatch[1] } return ""}
func ExtractFormAction(html []byte, formID string) string { htmlStr := string(html) pattern := `(?is)<form[^>]*\bid\s*=\s*["']` + regexp.QuoteMeta(formID) + `["'][^>]*>` re := regexp.MustCompile(pattern) match := re.FindString(htmlStr) if match == "" { return "" }
actionRe := regexp.MustCompile(`(?i)\baction\s*=\s*["']([^"']*)["']`) actionMatch := actionRe.FindStringSubmatch(match) if len(actionMatch) > 1 { return actionMatch[1] } return ""}
func ExtractAnyFormAction(html []byte, formIDs ...string) string { for _, id := range formIDs { if action := ExtractFormAction(html, id); action != "" { return action } } return ""}3.3 查询是否需要验证码(接口 3,可选)
用途:不同账号 / 安全等级,CAS 可能随机要求输入图形验证码。
GET https://ids.example.edu.cn/authserver/checkNeedCaptcha.htl?username={username}&_={ts}Cookie: {累积的Cookie}Referer: {登录页地址}响应:
{"isNeed": false}| 字段 | 类型 | 说明 |
|---|---|---|
isNeed | bool | true 表示本次需要验证码 |
该接口和登录共用一个会话,必须带同一份 Cookie。
Go 示例:
func (r *HttpSpyderCore) CheckNeedCaptcha(username string) (bool, error) { base := strings.TrimSuffix(r.baseUrl, "/login") finalURL := fmt.Sprintf("%s/checkNeedCaptcha.htl?username=%s&_=%d", base, url.QueryEscape(username), time.Now().UnixMilli())
req, err := http.NewRequest("GET", finalURL, nil) if err != nil { return false, err } for key, val := range r.headers { req.Header.Set(key, val) } req.Header.Set("Referer", fmt.Sprintf("%s?service=%s", r.baseUrl, r.targetService))
resp, err := r.client.Do(req) if err != nil { return false, err } defer func() { _ = resp.Body.Close() }() r.mergeCookiesFromResponse(resp)
body, err := io.ReadAll(resp.Body) if err != nil { return false, err }
trim := strings.TrimSpace(string(body)) if strings.HasPrefix(trim, "{") { var data struct { IsNeed bool `json:"isNeed"` } if err := json.Unmarshal([]byte(trim), &data); err != nil { return false, err } return data.IsNeed, nil } switch strings.ToLower(trim) { case "true": return true, nil case "false": return false, nil } return false, fmt.Errorf("unexpected checkNeedCaptcha resp: %q", trim)}3.4 拉取验证码图片(接口 4,可选)
用途:若接口 3 返回 isNeed: true,拉取一张 JPEG 图片,交由 OCR / 人工识别成字符串。
GET https://ids.example.edu.cn/authserver/getCaptcha.htl?{ts}Cookie: {累积的Cookie}Referer: {登录页地址}- 响应:
Content-Type: image/jpeg,返回图片字节流。 - 识别后的字符串供接口 6 使用。
Go 示例:
func (r *HttpSpyderCore) GetCaptcha() ([]byte, error) { base := strings.TrimSuffix(r.baseUrl, "/login") finalURL := fmt.Sprintf("%s/getCaptcha.htl?%d", base, time.Now().UnixMilli())
req, err := http.NewRequest("GET", finalURL, nil) if err != nil { return nil, err } for key, val := range r.headers { req.Header.Set(key, val) } req.Header.Set("Referer", fmt.Sprintf("%s?service=%s", r.baseUrl, r.targetService))
resp, err := r.client.Do(req) if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() r.mergeCookiesFromResponse(resp)
if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("getCaptcha: unexpected status %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } if len(body) == 0 { return nil, errors.New("getCaptcha: empty body") } return body, nil}3.5 密码加密(接口 5,本地处理)
用途:登录表单里永远不是明文密码,而是 AES-CBC 加密后的密文。
加密流程:
明文 = 64位随机串 + 真实密码Key = pwdEncryptSalt(补齐/截断到 16 字节)IV = 16位随机字符串密文 = AES-128-CBC(明文, Key, IV) + PKCS#7 填充提交 = URLEncode( Base64(密文) )| 步骤 | 说明 |
|---|---|
| 生成 64 位随机串 | 拼在密码前面,防彩虹表 / 增加随机性 |
| Key 处理 | pwdEncryptSalt 不足 16 字节补 0,超过截断 |
| IV | 随机生成 16 位字符串 |
| 加密 | AES-128-CBC + PKCS#7 填充 |
| 编码 | Base64 -> URLEncode |
同样的密码每次加密结果都不同,避免了固定密文被比对 / 重放。
Go 最小示例:
func EncryptPasssword(password, salt string) (string, error) { randomStr, err := randomStringGo(64) if err != nil { return "", fmt.Errorf("random error: %w", err) } passwordStr := randomStr + password
key := []byte(strings.TrimSpace(salt)) if len(key) < 16 { key = append(key, make([]byte, 16-len(key))...) } else if len(key) > 16 { key = key[:16] }
iv, err := randomStringGo(16) if err != nil { return "", fmt.Errorf("gen IV fail: %w", err) }
encrypted, err := aesEncryptGo([]byte(passwordStr), key, []byte(iv)) if err != nil { return "", fmt.Errorf("AES encrypt fail: %w", err) }
encryptedBase64 := base64.StdEncoding.EncodeToString(encrypted) return url.QueryEscape(encryptedBase64), nil}3.6 提交登录表单(接口 6,核心)
用途:携带所有参数 POST 登录表单,换取带 ticket 的 302 重定向。
POST https://ids.example.edu.cn/authserver/loginContent-Type: application/x-www-form-urlencodedCookie: {累积的Cookie}Referer: {登录页地址}Origin: https://ids.example.edu.cn请求 Body(表单字段):
| 字段名 | 值 | 必填 | 说明 |
|---|---|---|---|
username | 账号(URL 编码) | ✅ | 用户名 |
password | 接口 5 的加密结果 | ✅ | 密文,非明文 |
captcha | 验证码字符串 | 视策略 | 需要验证码时填写 |
_eventId | submit | ✅ | 固定值 |
cllt | userNameLogin | ✅ | 登录方式标识 |
dllt | generalLogin | ✅ | 通用口令登录标识 |
lt | 空字符串 | ✅ | LoginTicket,一般为空 |
execution | 接口 2 拿到的令牌 | ✅ | CAS 一次性流程令牌 |
字段顺序固定,必须按
username -> password -> captcha -> _eventId -> cllt -> dllt -> lt -> execution拼接,否则服务端可能解析失败。
表单构建(来自 client/client.go):
func (r *HttpSpyderCore) GenLoginForm(username, encPassword, captcha, execution string) *strings.Reader { s := fmt.Sprintf( "username=%s&password=%s&captcha=%s&_eventId=submit&cllt=userNameLogin&dllt=generalLogin<=&execution=%s", url.QueryEscape(username), encPassword, url.QueryEscape(captcha), execution, ) return strings.NewReader(s)}响应:
| 状态 | 说明 |
|---|---|
| 302 Found | 成功,Location 头含 ticket=ST-xxx |
| 200 OK | 失败或需要二次确认(见 3.7) |
成功时的 Location 示例:
Location: http://jwzx.example.edu.cn/index?ticket=ST-1662092-4ObMbxr-casGo 示例:
func (r *HttpSpyderCore) GetLocation(body *strings.Reader) (string, error) { finalURL := r.loginPostURL()
req, err := http.NewRequest("POST", finalURL, body) if err != nil { return "", err } for key, val := range r.headers { req.Header.Set(key, val) } req.Header.Set("Referer", fmt.Sprintf("%s?service=%s", r.baseUrl, r.targetService)) req.Header.Set("Origin", r.originOf(r.baseUrl))
resp, err := r.client.Do(req) if err != nil { return "", err } r.mergeCookiesFromResponse(resp)
if loc := resp.Header.Get("Location"); loc != "" { _ = resp.Body.Close() return loc, nil }
if resp.StatusCode == http.StatusOK { html, err := io.ReadAll(resp.Body) _ = resp.Body.Close() if err != nil { return "", err } if strings.Contains(string(html), "\u8e22\u51fa\u4f1a\u8bdd") { return r.confirmKickoutAndGetLocation(html) } } _ = resp.Body.Close() return "", nil}3.7 踢出会话二次确认(接口 7,异常分支)
用途:部分账号已在其他终端登录,CAS 返回 200 + 提示页面,而非 302。需要再 POST 一次确认踢出。
POST https://ids.example.edu.cn/authserver/loginContent-Type: application/x-www-form-urlencodedCookie: {累积的Cookie}请求 Body:
execution={新页面里的新execution}&_eventId=continue- 这次提交后才返回带
ticket的Location。
Go 示例:
func (r *HttpSpyderCore) confirmKickoutAndGetLocation(html []byte) (string, error) { exec2 := extractor.ExtractInputValue(html, `name="execution"`) if exec2 == "" { return "", errors.New("kickout page: execution not found") }
form := fmt.Sprintf("execution=%s&_eventId=continue", url.QueryEscape(exec2)) req, err := http.NewRequest("POST", r.loginPostURL(), strings.NewReader(form)) if err != nil { return "", err } for key, val := range r.headers { req.Header.Set(key, val) } req.Header.Set("Referer", fmt.Sprintf("%s?service=%s", r.baseUrl, r.targetService)) req.Header.Set("Origin", r.originOf(r.baseUrl))
resp, err := r.client.Do(req) if err != nil { return "", err } defer func() { _ = resp.Body.Close() }() r.mergeCookiesFromResponse(resp)
if loc := resp.Header.Get("Location"); loc != "" { return loc, nil } return "", errors.New("kickout confirm: no Location header")}4. 完整调用编排
一次完整的 CAS 登录按”开会话 -> 取参数 -> (可选)验证码 -> 加密密码 -> 提交 -> 拿 ticket”编排。
4.1 阶段 A:开启会话
GET /authserver/login?service={service}-> 拿到JSESSIONID等 Cookie。
4.2 阶段 B:获取登录参数
GET /authserver/login?service={service}(带 Cookie)-> 从 HTML 抠出execution、pwdEncryptSalt、formAction。
4.3 阶段 C:验证码
GET /authserver/checkNeedCaptcha.htl?username={username}->{"isNeed": true/false}。- 若
isNeed: true:GET /authserver/getCaptcha.htl-> 图片 -> OCR 识别。
4.4 阶段 D:加密并提交
- 本地:
64位随机串 + 密码-> AES-CBC -> Base64 -> URLEncode。 POST /authserver/login(表单含username、加密password、execution等)。
4.5 阶段 E:拿到 ticket
- 解析 302 响应的
Location头 -> 得到http://jwzx.xxx?ticket=ST-xxx。 - 再 GET 这个 URL(带 Cookie)-> 业务系统验票通过 -> 登录完成。
4.6 时序图
顺序约束:接口 1 -> 2 必须按序(Cookie 依赖);接口 3/4 仅在
isNeed: true时执行;接口 6 必须在接口 2、5 完成后才能调用。
5. 相关项目
本文档基于对 CAS 登录流程的抓包分析整理而成。
目标平台基础地址:
https://ids.cqupt.edu.cn/authserver/login # CAS 登录入口
6. 结尾
没有了喵,谢谢阅读

文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!