[Auto CQUPT Plan] CAS 统一认证登录逆向(补档)

2727 字
14 分钟
[Auto CQUPT Plan] CAS 统一认证登录逆向(补档)

1. 背景#

🐛邮 里面几乎所有服务的认证几乎都依赖 https://ids.cqupt.edu.cn ,所以只要拿下这个认证的流程,那我爬 🐛邮 里的服务处理认证时不是轻松又优雅。本文整理了 CAS 认证的整个流程,以及相关的API调用链,方便开发自动化脚本。

Warning

免责声明:

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-Typeapplication/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获取会话 CookieGET/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/login302 -> Locationticket=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 解析器提取:

参数说明
executionCAS 一次性流程令牌,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="..."ExtractAnyFormActionpwdFromId -> 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}
字段类型说明
isNeedbooltrue 表示本次需要验证码

该接口和登录共用一个会话,必须带同一份 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/login
Content-Type: application/x-www-form-urlencoded
Cookie: {累积的Cookie}
Referer: {登录页地址}
Origin: https://ids.example.edu.cn

请求 Body(表单字段):

字段名必填说明
username账号(URL 编码)用户名
password接口 5 的加密结果密文,非明文
captcha验证码字符串视策略需要验证码时填写
_eventIdsubmit固定值
clltuserNameLogin登录方式标识
dlltgeneralLogin通用口令登录标识
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&lt=&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-cas

Go 示例:

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/login
Content-Type: application/x-www-form-urlencoded
Cookie: {累积的Cookie}

请求 Body:

execution={新页面里的新execution}&_eventId=continue
  • 这次提交后才返回带 ticketLocation

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:开启会话#

  1. GET /authserver/login?service={service} -> 拿到 JSESSIONID 等 Cookie。

4.2 阶段 B:获取登录参数#

  1. GET /authserver/login?service={service}(带 Cookie)-> 从 HTML 抠出 executionpwdEncryptSaltformAction

4.3 阶段 C:验证码#

  1. GET /authserver/checkNeedCaptcha.htl?username={username} -> {"isNeed": true/false}
  2. isNeed: trueGET /authserver/getCaptcha.htl -> 图片 -> OCR 识别。

4.4 阶段 D:加密并提交#

  1. 本地:64位随机串 + 密码 -> AES-CBC -> Base64 -> URLEncode。
  2. POST /authserver/login(表单含 username、加密 passwordexecution 等)。

4.5 阶段 E:拿到 ticket#

  1. 解析 302 响应的 Location 头 -> 得到 http://jwzx.xxx?ticket=ST-xxx
  2. 再 GET 这个 URL(带 Cookie)-> 业务系统验票通过 -> 登录完成。

4.6 时序图#

sequenceDiagram participant C as 客户端(Go net/http) participant S as CAS Server (IDS) participant B as 业务系统 Note over C,S: 阶段 A · 开启会话 C->>S: GET /authserver/login?service=...(拿 JSESSIONID) S-->>C: Set-Cookie: JSESSIONID=xxx Note over C,S: 阶段 B · 获取登录参数 C->>S: GET /authserver/login?service=...(带 Cookie) S-->>C: HTML 响应(含 execution / pwdEncryptSalt) Note over C,S: 阶段 C · 验证码(可选) C->>S: GET /authserver/checkNeedCaptcha.htl S-->>C: {"isNeed": true/false} alt isNeed = true C->>S: GET /authserver/getCaptcha.htl S-->>C: JPEG 图片 Note over C: OCR 识别验证码 end Note over C: 阶段 D · 加密密码 Note over C: 随机串+密码 -> AES-CBC -> Base64 -> URLEncode Note over C,S: 阶段 E · 提交登录 C->>S: POST /authserver/login(表单:username/password/execution/...) S-->>C: 302 Found, Location: service?ticket=ST-xxx Note over C,B: 阶段 F · 业务系统验票 C->>B: GET service?ticket=ST-xxx B->>S: 验证 ticket 有效性 S-->>B: 验证通过 B-->>C: 200 OK(进入业务系统)

顺序约束:接口 1 -> 2 必须按序(Cookie 依赖);接口 3/4 仅在 isNeed: true 时执行;接口 6 必须在接口 2、5 完成后才能调用。

5. 相关项目#

Auto-CQUPT-Plan
/
CQUPT-CAS-SDK
Waiting for api.github.com...
00K
0K
0K
Waiting...

本文档基于对 CAS 登录流程的抓包分析整理而成。

目标平台基础地址:

https://ids.cqupt.edu.cn/authserver/login # CAS 登录入口

6. 结尾#

没有了喵,谢谢阅读

gopher
gopher

文章分享

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

[Auto CQUPT Plan] CAS 统一认证登录逆向(补档)
https://blog.tuf3i.cc/posts/acp-cas/
作者
TuF3i
发布于
2026-08-19
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
TuF3i
一只区,什么也不想写
分类
标签
站点统计
文章
16
分类
9
标签
18
总字数
43,299
运行时长
0
最后活动
0 天前

目录