Files
cpdaily_xiaozhushou/backed/internal/handler/auth_helper.go
T
becomer 07cf121aac 修 cookie 自动续期后仍失效时把内部错误码暴露给用户
现象(用户反馈):
  获取签到表单失败: 获取当日任务列表失败: 解析任务列表响应失败: cpdaily_cookie_expired

期望:能自动续期就续期;续不了就明确引导重新登录(前端据此跳登录页)。
实际:既没提示重新登录,还把内部错误码 cpdaily_cookie_expired 直接显示出来。

根因:自动续期成功、但用新 cookie 重试仍判登录态失效时,
重新拿到的错误(仍是 ErrCookieExpired)被原样返回。
而 needReLoginSignal() 只认 ErrPasswordChanged / ErrNeedReLogin,
不认识 ErrCookieExpired,于是:
  - errMsg 保持 "前缀 + cpdaily_cookie_expired";
  - needReLogin=false,前端不会引导重新登录。

这属于「续期拿到了凭证,但凭证其实无效」的情形(长期凭证过期、
学校侧强制下线等),语义上等同于必须重新登录。

改动:
1. withCookieRefresh:续期后重试仍报登录态失效 → 升级为 ErrNeedReLogin,
   这是所有 withCookieRefresh 调用方的统一入口,一处修全覆盖;
2. 签到执行路径:续期后重试仍过期同样升级,并加注释说明;
3. 新增 userFacingError(prefix, err):输出前统一兜底,
   任何残留的登录态失效语义都转成「登录凭证已过期,请重新登录」,
   普通错误保留原文。即使某条链路漏做升级,也不会再泄露内部错误码;
4. 「获取签到表单」与「签到提交」两处失败文案改用 userFacingError,
   needReLogin 一并由它给出(前端据此跳登录页)。

测试:原始 ErrCookieExpired、多层 %w 包装、ErrNeedReLogin、
JSON 文本「未登录」均转成引导重登文案且不含内部错误码;
普通错误原样保留;nil 不触发重登。
2026-10-18 16:36:31 +08:00

283 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"context"
"encoding/base64"
"errors"
"fmt"
"log/slog"
"strings"
"cpdaily-new/internal/database"
"cpdaily-new/internal/model"
cpd "cpdaily-new/internal/service/cpdaily"
)
// ErrPasswordChanged 当 cpdaily 密码错误时返回此错误
var ErrPasswordChanged = errors.New("cpdaily_password_error")
// ErrNeedReLogin 当登录态失效且无法自动续期(如手机号登录无密码无法自动重登)、
// 需要用户手动重新登录时返回此错误。与 ErrPasswordChanged 一起作为前端「重新登录」引导的信号。
var ErrNeedReLogin = errors.New("cpdaily_need_relogin")
// needReLoginSignal 判定错误是否需要引导用户重新登录。
// 只要登录凭证已过期且无法自动续期(密码错误 或 无密码无法自动重登等),都返回 true。
func needReLoginSignal(err error) bool {
return errors.Is(err, ErrPasswordChanged) || errors.Is(err, ErrNeedReLogin)
}
// wrapNeedReLogin 把「cookie 过期后自动续期失败」的错误升级为「需要重新登录」信号:
// 密码错误保持 ErrPasswordChanged 原语义;其余失败(无密码、MFA、学校接口异常等)
// 统一包装为 ErrNeedReLogin,让上层能识别并引导用户重新登录。
func wrapNeedReLogin(e error) error {
if errors.Is(e, ErrPasswordChanged) {
return ErrPasswordChanged
}
return fmt.Errorf("%w: %v", ErrNeedReLogin, e)
}
// encryptRawPassword AES 加密明文密码并 base64 编码
func encryptRawPassword(rawPassword string) (string, error) {
encrypted := cpd.AesEncrypt([]byte(rawPassword))
return base64.StdEncoding.EncodeToString(encrypted), nil
}
// decryptRawPassword base64 解码后 AES 解密
func decryptRawPassword(encrypted string) (string, error) {
data, err := base64.StdEncoding.DecodeString(encrypted)
if err != nil {
return "", err
}
decrypted := cpd.AesDecrypt(data)
return string(decrypted), nil
}
// saveRawPassword 将明文密码 AES 加密后存入数据库
func saveRawPassword(userID uint, rawPassword string) error {
encrypted, err := encryptRawPassword(rawPassword)
if err != nil {
return err
}
return database.DB.Model(&model.SysUser{}).Where("id = ?", userID).
Update("raw_password", encrypted).Error
}
// updateRawPassword 更新用户的 raw_password(密码可能已修改)
func updateRawPassword(userID uint, rawPassword string) error {
return saveRawPassword(userID, rawPassword)
}
// isCookieExpired 判断错误是否由 cpdaily 登录态失效(cookie 过期)引起。
// 两种来源:
// 1. cpd.ErrCookieExpired:响应体是 HTML(被重定向到统一身份认证登录页),
// 这是最常见、也最容易被漏判的情形(原文案匹配不到 "invalid character '<'");
// 2. 接口以 JSON 形式返回的「未登录 / 登录已过期」。
func isCookieExpired(err error) bool {
if err == nil {
return false
}
if errors.Is(err, cpd.ErrCookieExpired) {
return true
}
msg := err.Error()
return strings.Contains(msg, "未登录") ||
strings.Contains(msg, "登录已过期")
}
// isCpdailyAuthError 语义化别名:今日校园登录态失效。
func isCpdailyAuthError(err error) bool {
return isCookieExpired(err)
}
// refreshCpdailyCookie 使用数据库中的 raw_password 重新登录 cpdaily 并更新 cookie
// 密码错误时返回 ErrPasswordChanged
func refreshCpdailyCookie(user *model.SysUser) error {
// 重新从数据库加载用户(获取最新 raw_password 与 cookie)
var fresh model.SysUser
if err := database.DB.First(&fresh, user.ID).Error; err != nil {
slog.Error("刷新cookie: 查询用户失败", "userId", user.ID, "error", err)
return err
}
// 优先尝试用长期 cookie(sessionToken/CASTGC)直接换取新的 CAS cookie。
// 手机号验证码登录的账号没有 raw_password,只能走此路径自动续期。
if refreshed, err := refreshCookieByLongTermToken(&fresh); err == nil && refreshed != "" {
if err := database.DB.Model(&fresh).Update("cookie", refreshed).Error; err != nil {
slog.Warn("刷新cookie: 写回长期凭证续期结果失败", "userId", user.ID, "error", err)
} else {
slog.Info("刷新cookie: 已用长期凭证续期成功", "userId", user.ID)
return nil
}
} else if err != nil {
slog.Info("刷新cookie: 长期凭证续期未成功,回退密码重登", "userId", user.ID, "error", err)
}
if fresh.RawPassword == "" {
slog.Warn("刷新cookie: 未保存密码", "userId", user.ID)
return fmt.Errorf("%w: 未保存登录密码,无法自动重新登录", ErrNeedReLogin)
}
// 解密密码
rawPassword, err := decryptRawPassword(fresh.RawPassword)
if err != nil {
slog.Error("刷新cookie: 解密密码失败", "userId", user.ID, "error", err)
return err
}
// 获取学校详情
school, err := cpd.GetSchoolDetail(fresh.SchoolID)
if err != nil {
slog.Error("刷新cookie: 获取学校详情失败", "userId", user.ID, "schoolId", fresh.SchoolID, "error", err)
return err
}
slog.Info("刷新cookie: 尝试重新登录cpdaily", "userId", user.ID, "school", school.Name)
// 执行 cpdaily 登录
cookies, mfaCtx, err := cpd.CpdailyLogin(context.Background(), school, fresh.StudentID, rawPassword)
if err != nil && !errors.Is(err, cpd.ErrMFARequired) {
errMsg := err.Error()
slog.Warn("刷新cookie: cpdaily登录失败", "userId", user.ID, "error", errMsg)
// 检测密码错误
if strings.Contains(errMsg, "账号或密码错误") ||
strings.Contains(errMsg, "密码错误") ||
strings.Contains(errMsg, "密码不匹配") ||
strings.Contains(errMsg, "账号密码") {
return ErrPasswordChanged
}
return err
}
// 多因子认证学校:重新登录需人工输入验证码,无法自动续期
if errors.Is(err, cpd.ErrMFARequired) || mfaCtx != nil {
slog.Warn("刷新cookie: 该账号登录需多因子认证,无法自动续期", "userId", user.ID, "school", school.Name)
return fmt.Errorf("%w: 该账号所属学校开启登录验证码校验,需重新登录完成验证", ErrNeedReLogin)
}
// 检查 MOD_ cookie 确保登录成功
hasMOD := false
for _, cookie := range cookies {
if strings.HasPrefix(cookie.Name, "MOD_") {
hasMOD = true
break
}
}
if !hasMOD {
slog.Warn("刷新cookie: 未获取到MOD_cookie", "userId", user.ID)
return ErrPasswordChanged
}
// 序列化并更新 cookie
cookieStr := cpd.SerializeCookies(cookies)
if err := database.DB.Model(&fresh).Update("cookie", cookieStr).Error; err != nil {
slog.Error("刷新cookie: 更新数据库失败", "userId", user.ID, "error", err)
return err
}
// 先更新传入用户的 cookie(引用),上层重试与后续同步均使用最新值
user.Cookie = cookieStr
// 同步用户信息(异步,禁续期:避免「续期 → 同步 → 再续期」递归)
go syncUserProfileWith(user, cookieStr, school.WecloudUrl, false)
slog.Info("刷新cookie: 成功", "userId", user.ID)
return nil
}
// refreshCookieByLongTermToken 用已保存的长期 cookie(sessionToken/CASTGC)重新换取 CAS cookie。
// 适用于手机号验证码登录(无 raw_password)的账号自动续期;失败时返回错误,由调用方回退。
func refreshCookieByLongTermToken(user *model.SysUser) (string, error) {
cookieMap := cpd.ParseCookieJSON(user.Cookie)
if cookieMap["sessionToken"] == "" {
return "", errors.New("缺少长期凭证 sessionToken")
}
school, err := cpd.GetSchoolDetail(user.SchoolID)
if err != nil {
return "", err
}
cookies, err := cpd.RefreshCasCookie(context.Background(), cookieMap, school.WecloudUrl, user.MobileCode)
if err != nil {
return "", err
}
hasMOD := false
for _, c := range cookies {
if strings.HasPrefix(c.Name, "MOD_") {
hasMOD = true
break
}
}
if !hasMOD {
return "", errors.New("续期未获取到 MOD_ cookie")
}
cookieStr := cpd.SerializeCookies(cookies)
// 回写传入用户,供上层重试使用
user.Cookie = cookieStr
return cookieStr, nil
}
// withCookieRefresh 执行一次今日校园调用;若因登录态失效失败,则尝试续期 cookie 后重试一次。
// - 返回 (调用结果, 续期后的 cookieMap, error)。cookieMap 有变化时调用方可用于后续调用。
// - 续期失败(如 MFA 学校无法自动续期、密码已改)时,返回错误里带上 ErrCookieExpired,
// 由上层转成「登录凭证已过期,请重新登录」引导用户重登。
//
// 说明:MFA 学校登录后签发的是短期 JWT,到期会要求重新登录,本就无需自动续期;
// 普通学校(非 MFA)的 cookie 会在此处用长期凭证/密码自动续期,避免用户被莫名踢出。
func withCookieRefresh(user *model.SysUser, cookieMap map[string]string, host string,
fn func(cm map[string]string) error) (map[string]string, error) {
err := fn(cookieMap)
if err == nil || !isCpdailyAuthError(err) {
return cookieMap, err
}
slog.Info("检测到今日校园登录态失效,尝试自动续期 cookie", "userId", user.ID, "host", host)
if refreshErr := refreshCpdailyCookie(user); refreshErr != nil {
// 续期失败:保留原始错误语义,让上层引导重新登录
slog.Warn("cookie 自动续期失败", "userId", user.ID, "error", refreshErr)
return cookieMap, fmt.Errorf("%w: %v", cpd.ErrCookieExpired, refreshErr)
}
// 用续期后的 cookie 重试一次
newMap := cpd.ParseCookieJSON(user.Cookie)
if retryErr := fn(newMap); retryErr != nil {
// 续期成功但重试仍判登录态失效:说明拿到的凭证依然无效
// (长期凭证过期、学校侧强制下线等),等同于需要用户重新登录。
// 若不在这里升级,原始 ErrCookieExpired 会一路透传,
// 最终在界面上显示 cpdaily_cookie_expired 这种内部错误码。
if isCpdailyAuthError(retryErr) {
slog.Warn("续期后重试仍提示登录态失效,判定需重新登录",
"userId", user.ID, "host", host, "error", retryErr)
return newMap, fmt.Errorf("%w: %v", ErrNeedReLogin, retryErr)
}
return newMap, retryErr
}
return newMap, nil
}
// userFacingError 把内部错误转成可直接展示给用户的文案。
//
// 背景:ErrCookieExpired 的文本是 `cpdaily_cookie_expired`,
// Go 的 `%w` 包装会把它拼进错误链外层(如
// "获取签到表单失败: cpdaily_cookie_expired"),
// 最终原样出现在界面上,用户完全看不懂。
//
// 统一在输出前做一次兜底:任何残留的登录态失效语义都转成
// 「登录凭证已过期,请重新登录」,并保留原始前缀便于定位。
// 这样即使某条调用链漏做了 wrapNeedReLogin,也不会把内部错误码暴露出去。
func userFacingError(prefix string, err error) (msg string, needReLogin bool) {
if err == nil {
return prefix, false
}
if needReLoginSignal(err) || isCookieExpired(err) {
if prefix == "" {
return "登录凭证已过期,请重新登录", true
}
return prefix + "登录凭证已过期,请重新登录", true
}
return prefix + err.Error(), false
}