Files
cpdaily_xiaozhushou/backed/internal/service/cpdaily/login_mfa.go
T

486 lines
19 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 cpdaily
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
"github.com/go-resty/resty/v2"
)
// ========== CAS 多因子认证(MFA / reAuth)==========
// 今日校园定制 CAS 的多因子认证扩展(reAuthCheck)。
// 触发场景:CAS 账密提交成功后不是直接签发 ST 票据,而是 302 到
// {base}/authserver/reAuthCheck/reAuthLoginView.do?isMultifactor=true&service=<encoded>
// 该页面渲染当前用户全部可用的二次认证方式(Tab / 更多菜单 / 旧版列表),
// 调用方应把方式列表返回给用户选择;用户选定后调用发码接口(按需先 changeReAuthType.do
// 切换服务端会话方式),用户取得验证码后再提交 reAuthSubmit.do 完成认证,
// 最后访问 /login?service= 完成票据交换。
// ErrMFARequired 表示本次登录触发了多因子认证,
// 调用方应为返回的 MFAContext 暂存并引导用户选择验证方式、获取验证码并提交。
var ErrMFARequired = fmt.Errorf("mfa_required")
// ErrMFASessionExpired 表示 MFA 中间会话已失效(Redis 过期 / reAuth_unauthorized / 会话被服务端作废),
// 调用方应引导用户重新登录(可用 errors.Is 判断)。
var ErrMFASessionExpired = fmt.Errorf("mfa_session_expired")
// MFAAuthMethod 一种可用的多因子验证方式(来自 MFA 页面渲染的可用方式列表)
type MFAAuthMethod struct {
Type string `json:"type"` // reAuthType 数字编码(3=短信 4=企业微信 5=今日校园 11=邮箱 12=钉钉 13=WeLink 15=公众号)
Name string `json:"name"` // 展示名(优先页面文案,缺省用内置映射)
Default bool `json:"default,omitempty"` // 页面默认选中项
}
// MFAContext 触发 MFA 后的中间会话状态(可 JSON 序列化存 Redis,TTL 内有效)
type MFAContext struct {
BaseURL string `json:"baseUrl"` // CAS 根地址,如 http://ids.hfpec.edu.cn
ContextPath string `json:"contextPath"` // CAS 上下文路径,默认 /authserver
Service string `json:"service"` // reAuth service(含 sessionToken)
ReAuthType string `json:"reAuthType"` // 当前/用户最后选定的方式:2=密码 3=短信 5=今日校园 11=邮箱 12=钉钉 13=WeLink 15=公众号
UserID string `json:"userId"` // 学工号
AuthMethods []MFAAuthMethod `json:"authMethods,omitempty"` // 页面支持的全部可用验证方式
WecloudUrl string `json:"wecloudUrl"` // 完成登录后 activateSession 所需
CookiesByHost map[string][]*http.Cookie `json:"cookiesByHost"` // CAS 阶段会话(按域保存)
}
// MFASendResult 发送 MFA 验证码的结果
type MFASendResult struct {
Mobile string `json:"mobile,omitempty"` // 收码目标(手机号等,可能带掩码)
CodeTime int `json:"codeTime,omitempty"` // 可再次发送的倒计时秒数(0 表示不限制/未提供)
Message string `json:"message,omitempty"` // 服务端提示文案
}
// reAuthType → 发码接口的 authCodeTypeName 映射(源自 mobile-reAuth.js sendDynamicCodeByPhone)
var reAuthCodeTypeNames = map[string]string{
"3": "reAuthDynamicCodeType", // 手机短信
"4": "reAuthWChatDynamicCodeType", // 企业微信
"5": "reAuthCpdailyDynamicCodeType", // 今日校园
"11": "reAuthEmailDynamicCodeType", // 邮箱
"12": "reAuthDingTalkDynamicCodeType", // 钉钉
"13": "reAuthWeLinkDynamicCodeType", // WeLink
"15": "reAuthWeChatServiceDynamicCodeType", // 微信公众号
}
// reAuthType → 默认展示名(页面 Tab 无文案时兜底)
var reAuthTypeNames = map[string]string{
"2": "密码验证",
"3": "短信验证码",
"4": "企业微信验证码",
"5": "今日校园验证码",
"6": "实人认证",
"7": "安全问题",
"9": "QQ扫码",
"10": "安全令牌OTP",
"11": "邮箱验证",
"12": "钉钉验证码",
"13": "WeLink验证码",
"15": "公众号验证码",
}
// 需要发送动态码的 reAuthType 集合(密码/人脸/密保问题等类型不走发码接口)
func reAuthTypeNeedsDynamicCode(reAuthType string) bool {
_, ok := reAuthCodeTypeNames[reAuthType]
return ok
}
var (
reAuthParamsBlockRegexp = regexp.MustCompile(`var\s+reAuthParams\s*=\s*\{([\s\S]*?)\n\s*\}`)
reAuthTypeRegexp = regexp.MustCompile(`"reAuthType"\s*:\s*"([^"]*)"`)
reAuthServiceRegexp = regexp.MustCompile(`"service"\s*:\s*"([^"]*)"`)
reAuthUserIdRegexp = regexp.MustCompile(`"reAuthUserId"\s*:\s*"([^"]*)"`)
contextPathRegexp = regexp.MustCompile(`contextPath\s*=\s*"([^"]*)"`)
trailingDigitsRegexp = regexp.MustCompile(`(\d+)$`)
)
// 页面可用验证方式选择器(兼容历代模板;注释块内的元素不会被 goquery 匹配):
// .mobile-reauth-tab-item[data-type] 移动端 Tab(如 id="mobile_tab_5")
// .reauth-tab-item[data-type] 新版统一模板 Tab(如 id="tab_13")
// .reauth-tab-more-item[id] "更多"菜单(id 即编号)
// .changeReAuthTypes[id] 旧版列表(id 即编号)
// .other_type_button[id] "选择认证方式"弹窗按钮(如许昌学院 xcuTheme,id 即编号)
const mfaMethodSelectors = ".mobile-reauth-tab-item, .reauth-tab-item, .reauth-tab-more-item, .changeReAuthTypes, .other_type_button"
// parseMFAAuthMethods 从 MFA 页面解析全部可用验证方式(返回页面渲染顺序)
func parseMFAAuthMethods(page string) []MFAAuthMethod {
doc, err := goquery.NewDocumentFromReader(strings.NewReader(page))
if err != nil {
return nil
}
var methods []MFAAuthMethod
seen := make(map[string]bool)
doc.Find(mfaMethodSelectors).Each(func(_ int, s *goquery.Selection) {
reAuthType := strings.TrimSpace(s.AttrOr("data-type", ""))
if reAuthType == "" {
// 无 data-type 时从 id 提取尾部数字:mobile_tab_5 / tab_13 / 5 均可
reAuthType = trailingDigitsRegexp.FindString(s.AttrOr("id", ""))
}
if reAuthType == "" || seen[reAuthType] {
return
}
name := strings.TrimSpace(s.Find(".mobile-reauth-tab-text").Text())
if name == "" {
name = strings.TrimSpace(s.Text())
}
if name == "" {
name = reAuthTypeNames[reAuthType]
}
if name == "" {
name = "验证方式" + reAuthType
}
seen[reAuthType] = true
methods = append(methods, MFAAuthMethod{
Type: reAuthType,
Name: name,
Default: s.HasClass("mobile-reauth-tab-active") || s.HasClass("reauth-tab-active"),
})
})
return methods
}
// normalizeMFAAuthMethods 规范化可用验证方式列表:
// 1. 仅保留能接收动态码、本流程可完成的方式(短信/企业微信/今日校园/邮箱/钉钉/WeLink/公众号);
// 2. 过滤后为空时保留原列表(页面异常时前端仍能看到可选方式,发码时会得到明确提示);
// 3. 列表整体为空(页面无 Tab / 解析失败)且当前方式支持动态码时,用当前方式兜底为唯一选项,
// 避免前端「选择验证方式」无项可选、流程走不通;
// 4. 页面未标记默认项时,按 defaultType 标记,最后兜底标记首项。
func normalizeMFAAuthMethods(methods []MFAAuthMethod, defaultType string) []MFAAuthMethod {
var supported []MFAAuthMethod
for _, m := range methods {
if reAuthTypeNeedsDynamicCode(m.Type) {
supported = append(supported, m)
}
}
if len(supported) > 0 {
methods = supported
}
if len(methods) == 0 {
if !reAuthTypeNeedsDynamicCode(defaultType) {
return methods
}
// 页面无 Tab(解析失败或旧版模板):用当前 reAuthType 兜底为唯一方式
name := reAuthTypeNames[defaultType]
if name == "" {
name = "验证方式" + defaultType
}
return []MFAAuthMethod{{Type: defaultType, Name: name, Default: true}}
}
if !hasDefaultMFAMethod(methods) {
for i := range methods {
if methods[i].Type == defaultType {
methods[i].Default = true
break
}
}
}
if !hasDefaultMFAMethod(methods) {
methods[0].Default = true
}
return methods
}
// hasDefaultMFAMethod 列表内是否已有默认项
func hasDefaultMFAMethod(methods []MFAAuthMethod) bool {
for _, m := range methods {
if m.Default {
return true
}
}
return false
}
// handleMFATriggered 处理 CAS 登录后 302 到 MFA 页面的情况:
// 解析 MFA 页面参数与全部可用验证方式,返回 MFAContext 供用户选择方式、发送并提交验证码。
func handleMFATriggered(ctx context.Context, resp *resty.Response, school *SchoolDetailInfo, sc *sessionClient) (*MFAContext, error) {
location, err := resp.RawResponse.Location()
if err != nil {
return nil, fmt.Errorf("获取MFA重定向地址失败: %w", err)
}
// 恢复自动重定向(后续请求页面需要跟随)
sc.SetRedirectPolicy(resty.FlexibleRedirectPolicy(10))
pageResp, err := sc.R().SetContext(ctx).Get(location.String())
if err != nil {
return nil, fmt.Errorf("请求MFA页面失败: %w", err)
}
page := string(readBody(pageResp))
slog.Info("MFA页面获取", "url", location.String(), "bodyLen", len(page))
mfa := &MFAContext{
WecloudUrl: school.WecloudUrl,
}
// CAS 根地址与上下文路径
if u, err := url.Parse(location.String()); err == nil {
mfa.BaseURL = u.Scheme + "://" + u.Host
}
mfa.ContextPath = "/authserver"
if m := contextPathRegexp.FindStringSubmatch(page); len(m) > 1 {
mfa.ContextPath = strings.ReplaceAll(m[1], `\/`, "/")
}
// 解析 reAuthParams 块内的关键字段
block := reAuthParamsBlockRegexp.FindStringSubmatch(page)
if len(block) > 1 {
params := block[1]
if m := reAuthTypeRegexp.FindStringSubmatch(params); len(m) > 1 {
mfa.ReAuthType = m[1]
}
if m := reAuthServiceRegexp.FindStringSubmatch(params); len(m) > 1 {
mfa.Service = strings.ReplaceAll(m[1], `\/`, "/")
}
if m := reAuthUserIdRegexp.FindStringSubmatch(params); len(m) > 1 {
mfa.UserID = m[1]
}
}
// 兜底:service 从 302 Location 的 query 取
if mfa.Service == "" {
mfa.Service = location.Query().Get("service")
}
if mfa.ReAuthType == "" {
mfa.ReAuthType = "5"
slog.Warn("MFA页面未解析到reAuthType,按动态码处理", "base", mfa.BaseURL)
}
// 解析页面全部可用验证方式(Tab / 更多菜单 / 旧版列表)
mfa.AuthMethods = normalizeMFAAuthMethods(parseMFAAuthMethods(page), mfa.ReAuthType)
slog.Info("MFA参数解析", "reAuthType", mfa.ReAuthType, "userId", mfa.UserID,
"service", mfa.Service, "methods", len(mfa.AuthMethods))
// 不再自动发送验证码:由用户在前端选择验证方式后调用 SendMFADynamicCode 发送
if !reAuthTypeNeedsDynamicCode(mfa.ReAuthType) {
slog.Warn("MFA默认验证方式非动态码类型,需用户选择其他方式", "reAuthType", mfa.ReAuthType)
}
// 保存当前 CAS 会话(按域)
mfa.CookiesByHost = sc.jar.AllCookiesByHost()
return mfa, nil
}
// restoreMFACasSession 把触发 MFA 时保存的 CAS 会话恢复到新的会话客户端
func restoreMFACasSession(sc *sessionClient, mfa *MFAContext) {
for host, cookies := range mfa.CookiesByHost {
u := &url.URL{Scheme: "http", Host: host}
if strings.HasPrefix(mfa.BaseURL, "https://") {
u.Scheme = "https"
}
sc.jar.SetCookies(u, cookies)
}
}
// changeMFAAuthType 调用 changeReAuthType.do 把服务端 MFA 会话切换到指定验证方式
// POST {base}{contextPath}/reAuthCheck/changeReAuthType.do
func changeMFAAuthType(ctx context.Context, client *resty.Client, mfa *MFAContext, reAuthType string) error {
changeUrl := mfa.BaseURL + mfa.ContextPath + "/reAuthCheck/changeReAuthType.do"
resp, err := client.R().SetContext(ctx).
SetFormData(map[string]string{
"isMultifactor": "true",
"reAuthType": reAuthType,
"service": mfa.Service,
}).
Post(changeUrl)
if err != nil {
return fmt.Errorf("切换MFA验证方式失败: %w", err)
}
body := readBody(resp)
slog.Info("MFA切换验证方式响应", "status", resp.StatusCode(), "reAuthType", reAuthType,
"body", string(body[:min(len(body), 500)]))
// code 兼容数字 1 与字符串 "1"(不同学校版本返回类型不一致)
var result struct {
Code interface{} `json:"code"`
Message string `json:"message"`
}
if err := json.Unmarshal(body, &result); err != nil {
return fmt.Errorf("切换MFA验证方式响应解析失败: %w", err)
}
if fmt.Sprintf("%v", result.Code) != "1" {
return fmt.Errorf("%s", orDefault(result.Message, "该验证方式暂不可用,请选择其他方式"))
}
return nil
}
// sendReAuthDynamicCodeResult 发码接口响应
type sendReAuthDynamicCodeResult struct {
Res string `json:"res"` // success/cpdaily_success/wechat_success/other_success/code_time_fail
ReturnMessage string `json:"returnMessage"` // 服务端提示文案
Mobile string `json:"mobile"` // 收码目标(手机号等)
CodeTime int `json:"codeTime"` // 可再次发送的倒计时秒数
ErrCode string `json:"errCode"` // 错误码(如 206302 会话失效)
}
// postReAuthDynamicCode 调用 CAS 发码接口发送验证码
// POST {base}{contextPath}/dynamicCode/getDynamicCodeByReauth.do
func postReAuthDynamicCode(ctx context.Context, client *resty.Client, mfa *MFAContext, authCodeTypeName string) (*sendReAuthDynamicCodeResult, error) {
sendUrl := mfa.BaseURL + mfa.ContextPath + "/dynamicCode/getDynamicCodeByReauth.do"
resp, err := client.R().SetContext(ctx).
SetFormData(map[string]string{
"userName": mfa.UserID,
"authCodeTypeName": authCodeTypeName,
}).
Post(sendUrl)
if err != nil {
return nil, fmt.Errorf("发送MFA验证码失败: %w", err)
}
body := readBody(resp)
slog.Info("MFA验证码发送响应", "status", resp.StatusCode(), "body", string(body[:min(len(body), 500)]))
var result sendReAuthDynamicCodeResult
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("发送MFA验证码响应解析失败: %w", err)
}
// 206302:服务端 MFA 会话已失效
if result.ErrCode == "206302" {
return nil, fmt.Errorf("%w: 验证会话已失效,请重新登录", ErrMFASessionExpired)
}
switch result.Res {
case "success", "cpdaily_success", "wechat_success", "other_success", "code_time_fail":
// 发送成功(code_time_fail 为倒计时未结束,验证码此前已发出)
return &result, nil
default:
msg := result.ReturnMessage
if msg == "" {
msg = "发送验证码失败,请稍后重试"
}
return nil, fmt.Errorf("%s", msg)
}
}
// SendMFADynamicCode 用户选定验证方式后发送动态验证码:
// 与当前方式不同时先调用 changeReAuthType.do 切换服务端会话,再调用发码接口;
// 成功后同步 mfa.ReAuthType,供后续 reAuthSubmit.do 提交使用。
func SendMFADynamicCode(ctx context.Context, mfa *MFAContext, reAuthType string) (*MFASendResult, error) {
if mfa == nil || len(mfa.CookiesByHost) == 0 {
return nil, fmt.Errorf("%w: 验证会话不存在,请重新登录", ErrMFASessionExpired)
}
if reAuthType == "" {
reAuthType = mfa.ReAuthType
}
if len(mfa.AuthMethods) > 0 && !mfaHasAuthMethod(mfa.AuthMethods, reAuthType) {
return nil, fmt.Errorf("不支持的验证方式,请选择其他方式")
}
if !reAuthTypeNeedsDynamicCode(reAuthType) {
name := reAuthTypeNames[reAuthType]
if name == "" {
name = "验证方式" + reAuthType
}
return nil, fmt.Errorf("「%s」暂不支持发送验证码,请选择其他方式", name)
}
loginCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
sc := newSessionClient()
restoreMFACasSession(sc, mfa)
// 与当前会话方式不同时先切换(与官方页面 Tab 切换行为一致)
if reAuthType != mfa.ReAuthType {
if err := changeMFAAuthType(loginCtx, sc.Client, mfa, reAuthType); err != nil {
return nil, err
}
mfa.ReAuthType = reAuthType
slog.Info("MFA验证方式已切换", "reAuthType", reAuthType)
}
result, err := postReAuthDynamicCode(loginCtx, sc.Client, mfa, reAuthCodeTypeNames[reAuthType])
if err != nil {
return nil, err
}
return &MFASendResult{
Mobile: result.Mobile,
CodeTime: result.CodeTime,
Message: orDefault(result.ReturnMessage, "验证码已发送"),
}, nil
}
// mfaHasAuthMethod 判断方式编号是否在可用列表中
func mfaHasAuthMethod(methods []MFAAuthMethod, reAuthType string) bool {
for _, m := range methods {
if m.Type == reAuthType {
return true
}
}
return false
}
// CompleteMFALogin 提交用户输入的验证码,完成 MFA 认证并走完 CAS 票据交换,
// 返回登录成功后的全部 cookies。
func CompleteMFALogin(ctx context.Context, mfa *MFAContext, code string) ([]*http.Cookie, error) {
if mfa == nil || len(mfa.CookiesByHost) == 0 {
return nil, fmt.Errorf("%w: 验证会话不存在,请重新登录", ErrMFASessionExpired)
}
loginCtx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
sc := newSessionClient()
// 恢复 CAS 阶段会话
restoreMFACasSession(sc, mfa)
// 1. 提交验证码(reAuthSubmit.do 返回 JSON,不涉及重定向)
submitUrl := mfa.BaseURL + mfa.ContextPath + "/reAuthCheck/reAuthSubmit.do"
submitResp, err := sc.R().SetContext(loginCtx).
SetFormData(map[string]string{
"service": mfa.Service,
"reAuthType": mfa.ReAuthType,
"isMultifactor": "true",
"password": "",
"dynamicCode": code,
"uuid": "",
"answer1": "",
"answer2": "",
"otpCode": "",
}).
Post(submitUrl)
if err != nil {
return nil, fmt.Errorf("提交MFA验证码失败: %w", err)
}
body := readBody(submitResp)
slog.Info("MFA验证码提交响应", "status", submitResp.StatusCode(), "body", string(body[:min(len(body), 500)]))
var result struct {
Code string `json:"code"`
Msg string `json:"msg"`
}
_ = json.Unmarshal(body, &result)
switch result.Code {
case "reAuth_failed":
return nil, fmt.Errorf("%s", orDefault(result.Msg, "验证码错误或已过期"))
case "reAuth_unauthorized":
return nil, fmt.Errorf("%w: %s", ErrMFASessionExpired, orDefault(result.Msg, "验证会话已失效,请重新登录"))
}
// 2. 完成 CAS 票据交换:GET /login?service=... → 302(service?ticket=) → wecloud /iap/loginSuccess
exchangeUrl := mfa.BaseURL + mfa.ContextPath + "/login?service=" + url.QueryEscape(mfa.Service)
sc.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
exchangeResp, err := sc.R().SetContext(loginCtx).Get(exchangeUrl)
if err != nil {
return nil, fmt.Errorf("MFA票据交换失败: %w", err)
}
_ = exchangeResp
// 3. 激活 wecloud 会话
activateSession(loginCtx, mfa.WecloudUrl, sc.Client)
cookies := sc.jar.AllCookies()
return cookies, nil
}
// orDefault 空串时取默认值
func orDefault(s, def string) string {
if strings.TrimSpace(s) == "" {
return def
}
return s
}