更正 CAS 注释的推断错误,并兼容 ticket 的三种参数名

上一提交 e38fd89 把「参数解码错误」归因于 url.QueryEscape 把密文空格
变成 '+'。本次用站点真实的 security.js 在 Node 里跑通加密链路后,
该归因被证伪:

- RSAUtils.chunkSize = 2 * biHighIndex(m) = 126(字符),
  与 Go 的 k-2 = 126 一致;
- 实测真实密钥下,典型 CAS ticket(数十字符)明文约 77 < 126,
  产物是**单块 256 hex、不含空格**,QueryEscape 与否完全等价;
- 用同一 DK/M/E 对拍,Go 与前端 security.js 的输出**逐字节一致**,
  说明加密实现本身没有问题。

因此「参数解码错误」更可能是 ticket 本身已失效/被消费
(CASTGC 过期、同一 ticket 重复使用),而非编码差异。
注释已改为陈述实测事实,不再保留错误推断。

保留的直接拼接本身无害且更贴近前端原文(避免将来 ticket 变长
产生多块时引入空格→'+' 的偏差),故不回退。

顺带补上真实存在的兼容差异:前端 OAuthCallback 从 query 里按
ticket / service_ticket / ST 三个名字依次取,我们原先只认 ticket。
This commit is contained in:
becomer
2026-10-17 23:08:54 +08:00
parent e38fd89425
commit 392c90ec11
2 changed files with 45 additions and 8 deletions
+17 -8
View File
@@ -78,15 +78,15 @@ func XsgzLogin(ctx context.Context, schoolID, casTicket string) (*XsgzSession, e
return nil, fmt.Errorf("ticket 加密失败: %w", err)
}
// 必须与前端完全一致:前端 getOAuthToken 是
// service.post("/cas/callback", `ticket=${s}`,
// {headers:{"Content-Type":"application/x-www-form-urlencoded"}})
// 即直接把密文拼进去,不做 URL 编码。
// body 与前端保持一致:前端 OAuthCallback 组件是
// const u = window.encrypt(ticket); await getOAuthToken(u);
// getOAuthToken(s): service.post("/cas/callback", `ticket=${s}`, ...)
// 即直接把密文拼进 body,不做 URL 编码。
//
// 密文由多块小写 hex 用**空格**连接,若用 url.QueryEscape 会把空格变成 '+'。
// 服务端若按原始 body 解析(不做 form 解码),'+' 不会被还原成空格,
// 逐块解密就会失败,返回「参数解码错误,刷新页面后尝试」——
// 这正是线上 userId=1490 遇到的报错。
// 说明:密文的实际长度取决于明文长度。用真实密钥与典型 CAS ticket
// (数十字符)验证,明文约 77 字节 < chunkSize 126,产物为**单块 256 hex**,
// 不含空格,因此这里是否做 QueryEscape 在当前长度下等价。
// 但仍按前端原文直接拼接,避免将来 ticket 变长产生多块时引入空格→'+' 的偏差。
resp, err := client.R().SetContext(ctx).
SetHeader("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8").
SetBody("ticket=" + enc).
@@ -741,7 +741,16 @@ func CasTicketForService(ctx context.Context, cfg CasServiceConfig) (string, err
if perr != nil {
return "", fmt.Errorf("解析回调地址失败: %w", perr)
}
// 前端 OAuthCallback 组件从 query 里按 ticket / service_ticket / ST 三个
// 名字依次取(`const {ticket:a, service_ticket:e, ST:r} = n.query`),
// 这里同样兼容,避免 CAS 用其它名字回传时取不到。
ticket := u.Query().Get("ticket")
if ticket == "" {
ticket = u.Query().Get("service_ticket")
}
if ticket == "" {
ticket = u.Query().Get("ST")
}
if ticket == "" {
return "", fmt.Errorf("回调地址未携带 ticket")
}
@@ -520,3 +520,31 @@ func TestXsgzFetchLeaveDetailTopLevelWins(t *testing.T) {
t.Fatalf("顶层 projectId 应优先,实际 %q", d.ProjectID)
}
}
// TestCasTicketNameVariants CAS 回调 ticket 可能用三种参数名。
// 前端 OAuthCallback:const {ticket:a, service_ticket:e, ST:r} = n.query
func TestCasTicketNameVariants(t *testing.T) {
cases := []struct{ name, rawQuery string }{
{"ticket", "ticket=ST-1"},
{"service_ticket", "service_ticket=ST-2"},
{"ST", "ST=ST-3"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
u, err := url.Parse("https://xsgz.sdau.edu.cn/oauth/callback?" + c.rawQuery)
if err != nil {
t.Fatal(err)
}
tk := u.Query().Get("ticket")
if tk == "" {
tk = u.Query().Get("service_ticket")
}
if tk == "" {
tk = u.Query().Get("ST")
}
if tk == "" {
t.Fatalf("参数名 %s 未取到 ticket", c.name)
}
})
}
}