修复手机号登录账号无法自动续期:RefreshCasCookie 未使用 CASTGC
ci/woodpecker/tag/build Pipeline was successful

手机号验证码登录的账号没有 raw_password,cookie 过期后只能靠长期凭证
续期。但 RefreshCasCookie 只发 AmpCookies 头、未把长期凭证 CASTGC(TGC)
预置进客户端,且不跟随 CAS 重定向,导致请求被 302 到登录页,续期必然失败
(表现为「解析续期响应失败: unexpected end of JSON input」),用户被误判
为登录失效。

对齐 ExchangeCasCookie:预置 CASTGC/sessionToken/tenantId 等 cookie,并
跟随 CAS 重定向(跨跳保留原请求头),使网关能换回 MOD_AUTH_CAS。

新增 sms_login_test.go:以 mock 网关覆盖「带 CASTGC 续期成功」与
「缺 sessionToken 直接报错」;旧代码下前者复现同一失败,修复后通过。
This commit is contained in:
becomer
2026-10-08 14:34:13 +08:00
parent 7f39b39643
commit d56769b265
2 changed files with 122 additions and 0 deletions
@@ -311,6 +311,50 @@ func RefreshCasCookie(ctx context.Context, cookieMap map[string]string, host str
}
client := newSmsClient()
// 与 ExchangeCasCookie 保持一致:跟随 CAS 重定向,且跨跳时保留原请求头
// (否则 UPDATE 网关 302 到 /iap/login 后不跟随,或跟随中丢失 AmpCookies/SessionToken,
// 都会落到登录页 HTML,无法换回 MOD_AUTH_CAS)。
client.SetRedirectPolicy(resty.RedirectPolicyFunc(func(req *http.Request, via []*http.Request) error {
if len(via) >= 50 {
return fmt.Errorf("重定向次数过多")
}
for key, val := range via[0].Header {
if !strings.EqualFold(key, "Cookie") {
req.Header[key] = val
}
}
return nil
}))
// 关键:把长期凭证(CASTGC 等)连同基础 cookie 预置进 jar。
// 手机号登录时 CASTGC(TGC)会随登录响应一起保存,是真正的长期 SSO 凭证;
// 仅凭 sessionToken 头(AmpCookies)无法续期——网关会 302 到登录页。
// 此前这里未预置 CASTGC,导致手机号登录账号一旦 MOD_AUTH_CAS 过期便无法自动续期。
presetCookies := map[string]string{
"clientType": "cpdaily_student",
"standAlone": "0",
"sessionToken": sessionToken,
}
if v := cookieMap["tenantId"]; v != "" {
presetCookies["tenantId"] = v
}
if v := cookieMap["CASTGC"]; v != "" {
presetCookies["CASTGC"] = v
}
targetSchoolURL, err := url.Parse(strings.TrimSuffix(host, "/"))
if err != nil {
return nil, fmt.Errorf("解析学校地址失败: %w", err)
}
targetAPIURL, _ := url.Parse("https://api.campushoy.com")
for name, value := range presetCookies {
c := &http.Cookie{Name: name, Value: value}
if jar := client.GetClient().Jar; jar != nil {
jar.SetCookies(targetSchoolURL, []*http.Cookie{c})
if targetAPIURL != nil {
jar.SetCookies(targetAPIURL, []*http.Cookie{c})
}
}
}
ampCookieMap := map[string]any{
"AMP2": []map[string]any{
@@ -0,0 +1,78 @@
package cpdaily
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// 复刻学校网关行为:未携带有效 CASTGC 时,getImUserToken 会 302 到 CAS 登录页;
// 携带 CASTGC 时,CAS 登录页换发一次性 ST 并回调原 service(下发 MOD_AUTH_CAS)。
// 用于验证 RefreshCasCookie 是否(1)把长期凭证 CASTGC 预置进客户端、(2)跟随重定向。
func newRefreshMockServer(t *testing.T) *httptest.Server {
t.Helper()
const validTGT = "iap-test-TGT-abc"
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
// CAS 登录入口:带有效 TGT 则换发 ST 回调 service;否则踢回登录页
case r.URL.Path == "/iap/login":
service := r.URL.Query().Get("service")
if c, err := r.Cookie("CASTGC"); err == nil && c.Value == validTGT {
w.Header().Set("Location", service+"?ticket=ST-iap:test:ST:x:20260101120000")
w.WriteHeader(http.StatusFound)
return
}
w.Header().Set("Location", "/iap/login/pc.html")
w.WriteHeader(http.StatusFound)
// service 回调:带 ticket 时下发 MOD_AUTH_CAS(模拟 CAS 建立会话)
case r.URL.Path == "/wec-im-group/group/imUserToken/getImUserToken":
if strings.HasPrefix(r.URL.Query().Get("ticket"), "ST-iap:") {
http.SetCookie(w, &http.Cookie{Name: "MOD_AUTH_CAS", Value: r.URL.Query().Get("ticket"), Path: "/"})
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"errCode":0,"errMsg":"","data":{}}`))
return
}
// 无有效会话:302 到 CAS 登录入口
w.Header().Set("Location", "/iap/login?service="+r.URL.Path)
w.WriteHeader(http.StatusFound)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
}
// 携带 CASTGC 时应能续期成功并拿回 MOD_AUTH_CAS。
// 修复前 RefreshCasCookie 未预置 CASTGC,会落到登录页、解析 HTML 失败。
func TestRefreshCasCookiePresetsCASTGC(t *testing.T) {
srv := newRefreshMockServer(t)
defer srv.Close()
cookies, err := RefreshCasCookie(context.Background(), map[string]string{
"sessionToken": "st-test",
"tenantId": "lzufe",
"CASTGC": "iap-test-TGT-abc",
}, srv.URL, "device-1")
if err != nil {
t.Fatalf("续期应成功,实际报错: %v", err)
}
hasMOD := false
for _, c := range cookies {
if c.Name == "MOD_AUTH_CAS" {
hasMOD = true
}
}
if !hasMOD {
t.Fatalf("续期后应取得 MOD_AUTH_CAS,实际 cookies: %+v", cookies)
}
}
// 缺少 sessionToken 时保持既有保护:直接报错,不发请求。
func TestRefreshCasCookieRequiresSessionToken(t *testing.T) {
if _, err := RefreshCasCookie(context.Background(), map[string]string{
"CASTGC": "iap-test-TGT-abc",
}, "https://school.campusphere.net", "device-1"); err == nil {
t.Fatal("缺少 sessionToken 应报错")
}
}