更正 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:
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user