diff --git a/backed/internal/handler/auth_helper.go b/backed/internal/handler/auth_helper.go index a097871..5b73489 100644 --- a/backed/internal/handler/auth_helper.go +++ b/backed/internal/handler/auth_helper.go @@ -214,11 +214,41 @@ func refreshCookieByLongTermToken(user *model.SysUser) (string, error) { } cookieStr := cpd.SerializeCookies(cookies) + + // 关键:拿到 MOD_* 不等于续期有效。 + // RefreshCasCookie 走的 /imUserToken 接口只校验 sessionToken 头, + // 不校验 CAS 会话是否仍然有效;当 CASTGC/sessionToken 本身已过期时, + // 它仍可能返回 errCode=0 并把 jar 里(旧的/无效的)cookie 全数返回。 + // 于是这里会“续期成功”,而调用方重试立刻再次判定登录态失效。 + // + // 因此用新 cookie 做一次轻量真实校验:能取到当天任务列表才算真的续上了。 + // 校验失败则按续期失败处理,让上层引导重新登录,而不是白白重试一轮。 + if verifyErr := verifyCpdailyCookie(cookieStr, user); verifyErr != nil { + return "", fmt.Errorf("续期后凭证仍无效: %w", verifyErr) + } + // 回写传入用户,供上层重试使用 user.Cookie = cookieStr return cookieStr, nil } +// verifyCpdailyCookie 用指定 cookie 做一次只读校验,确认登录态真实可用。 +// +// 选择「拉当天任务列表」是因为它最轻、几乎所有学号都有数据, +// 且不会产生任何写入副作用(不签到、不改动任何服务端状态)。 +func verifyCpdailyCookie(cookieStr string, user *model.SysUser) error { + m := cpd.ParseCookieJSON(cookieStr) + if len(m) == 0 { + return errors.New("续期结果为空") + } + host := getSchoolHost(user.SchoolID) + if host == "" { + return errors.New("未配置学校地址") + } + // 复用与正式调用相同的过期检测逻辑 + return cpd.VerifyCookieUsable(m, host) +} + // withCookieRefresh 执行一次今日校园调用;若因登录态失效失败,则尝试续期 cookie 后重试一次。 // - 返回 (调用结果, 续期后的 cookieMap, error)。cookieMap 有变化时调用方可用于后续调用。 // - 续期失败(如 MFA 学校无法自动续期、密码已改)时,返回错误里带上 ErrCookieExpired, diff --git a/backed/internal/service/cpdaily/task.go b/backed/internal/service/cpdaily/task.go index e1054c4..88605ec 100644 --- a/backed/internal/service/cpdaily/task.go +++ b/backed/internal/service/cpdaily/task.go @@ -6,6 +6,7 @@ import ( "encoding/base64" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "log/slog" @@ -1718,3 +1719,43 @@ func faceProgressIsTerminalFailure(msg string) bool { } return false } + +// VerifyCookieUsable 用指定 cookie 做一次只读探测,确认登录态真实可用。 +// +// 起因:自动续期拿到 MOD_* cookie 后被判定「续期成功」,但紧接着的业务调用 +// 仍返回 cpdaily_cookie_expired。根因是续期走的 /imUserToken 接口只校验 +// sessionToken 头、不校验 CAS 会话,在 CASTGC/sessionToken 已失效时 +// 仍可能返回成功,并带回一批无效 cookie。 +// +// 因此这里用与业务相同的最小请求(拉当天任务列表)验证: +// - 返回 ErrCookieExpired ⇒ 凭证确实不可用; +// - 其它错误(网络/限流等)不归咎于凭证,向上返回但不当作过期; +// - 正常返回(哪怕列表为空)⇒ 登录态可用。 +// +// 该探测为只读,不会产生任何写操作副作用。 +func VerifyCookieUsable(cookieMap map[string]string, host string) error { + if len(cookieMap) == 0 { + return ErrCookieExpired + } + if host == "" { + return fmt.Errorf("学校地址为空") + } + // 依次尝试三种任务类型:任一能正常返回即说明会话有效。 + // (学生未必同时拥有签到与查寝任务,单类型返回空不代表失效。) + var lastErr error + for _, tt := range []TaskType{TaskTypeSign, TaskTypeSleepCheck, TaskTypeReturnRegister} { + if _, err := fetchTasksByEndpoint(cookieMap, host, tt, ""); err != nil { + if errors.Is(err, ErrCookieExpired) { + lastErr = err + continue // 该类过期,再试其它端点确认 + } + // 非过期类错误(网络等):不能据此判定凭证失效 + return nil + } + return nil // 任一端点可用即视为有效 + } + if lastErr != nil { + return lastErr + } + return nil +} diff --git a/backed/internal/service/cpdaily/task_verify_test.go b/backed/internal/service/cpdaily/task_verify_test.go new file mode 100644 index 0000000..b68fcaa --- /dev/null +++ b/backed/internal/service/cpdaily/task_verify_test.go @@ -0,0 +1,14 @@ +package cpdaily + +import "testing" + +// VerifyCookieUsable 的边界:空 cookie / 空 host 必须判为不可用或报错, +// 不能静默让上层以为凭证有效。 +func TestVerifyCookieUsableGuards(t *testing.T) { + if err := VerifyCookieUsable(nil, "https://x.edu.cn"); err == nil { + t.Fatal("空 cookie 应报错") + } + if err := VerifyCookieUsable(map[string]string{"MOD_AUTH_CAS": "x"}, ""); err == nil { + t.Fatal("空 host 应报错") + } +}