486 lines
19 KiB
Go
486 lines
19 KiB
Go
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
|
||
}
|