v1.2.41:许昌学院(xcu)单独适配 CAS 登录 + MFA 验证方式解析兼容 xcuTheme 弹窗布局——① 许昌学院:平台接口返回 joinType=CLOUD(IsIap=true),但其 IAP 账密接口 /iap/doLogin 与通用密码加密格式不兼容(实测 RSA hex/base64 多种编码均返回 FAIL_UPNOTMATCH,无法走通);学校真实链路为校园网关 302 → 学校 CAS(authserver.xcu.edu.cn)账密 + 多因子认证 → 回调 /iap/loginSuccess?sessionToken=... 建立网关会话,故在 CpdailyLogin 中对 xcu 特判强制走 CAS,登录入口由 ampUrl(casp.xcu.edu.cn,service 与 cookie 域不匹配)替换为 wecloud 网关 /portal/login(service 指向 xcu.campusphere.net,cookie 正确落在 wec-* 任务接口所在域),新增 login_xcu.go;② MFA 解析:许昌学院定制主题 xcuTheme 将验证方式渲染在「选择认证方式」弹窗中(div.other_type_button[id],id 即方式编号),原选择器均不匹配导致解析出 0 个方式(只能靠兜底),mfaMethodSelectors 新增 .other_type_button 兼容该布局;新增单测 5 个(xcu 入口计算 2 个 + MFA 弹窗布局 3 个),go test ./... 全过;已用真实账号端到端验证:CpdailyLogin → 网关入口 → CAS 账密成功 → MFA 触发并解析出 [{3 短信验证码 default:true}](修复前为 0 个)
ci/woodpecker/tag/build Pipeline was successful

This commit is contained in:
becomer
2026-09-13 21:50:54 +08:00
parent 1a15b359f8
commit 1507b386bc
5 changed files with 175 additions and 1 deletions
+2 -1
View File
@@ -106,7 +106,8 @@ var (
// .reauth-tab-item[data-type] 新版统一模板 Tab(如 id="tab_13")
// .reauth-tab-more-item[id] "更多"菜单(id 即编号)
// .changeReAuthTypes[id] 旧版列表(id 即编号)
const mfaMethodSelectors = ".mobile-reauth-tab-item, .reauth-tab-item, .reauth-tab-more-item, .changeReAuthTypes"
// .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 {
@@ -78,6 +78,65 @@ func TestParseMFAAuthMethodsLegacyLayout(t *testing.T) {
}
}
// 许昌学院 xcuTheme:「选择认证方式」弹窗(结构取自真实 MFA 页面,已脱敏)
const mfaOtherTypeDialogPageSample = `<html><body>
<script>var reAuthParams = {
"reAuthType":"3",
"isMultifactor":"true",
"service":"https:\/\/xcu.campusphere.net\/iap\/loginSuccess?sessionToken=test-token",
"reAuthUserId":"5810260823"
};</script>
<div id="other_dialog">
<div class="mask changeOtherType"></div>
<div class="dialog-reAuth changeOtherType" id="changeOtherType">
<div class="other_type_title other_type_header_title">选择认证方式</div>
<div id="3" class="other_type_button" onclick="changeMobileReAuthType(this)">短信验证码</div>
<div class="other_type_title cancel_bottom_btn" onclick="mobileChangeTypeCancel();">取消</div>
</div>
</div>
</body></html>`
func TestParseMFAAuthMethodsOtherTypeDialog(t *testing.T) {
methods := parseMFAAuthMethods(mfaOtherTypeDialogPageSample)
if len(methods) != 1 {
t.Fatalf("弹窗方式列表应解析出 1 个方式,实际 %d: %+v", len(methods), methods)
}
if methods[0].Type != "3" || methods[0].Name != "短信验证码" {
t.Errorf("弹窗方式解析错误(id 即编号、文本即名字): %+v", methods[0])
}
}
func TestParseMFAAuthMethodsOtherTypeDialogMulti(t *testing.T) {
// 多方式弹窗:标题不应被误解析,仅 .other_type_button 计入
page := `<html><body>
<div class="dialog-reAuth changeOtherType" id="changeOtherType">
<div class="other_type_title other_type_header_title">选择认证方式</div>
<div id="3" class="other_type_button">短信验证码</div>
<div id="11" class="other_type_button">邮箱验证</div>
<div class="other_type_title cancel_bottom_btn">取消</div>
</div>
</body></html>`
methods := parseMFAAuthMethods(page)
if len(methods) != 2 {
t.Fatalf("期望解析出 2 个方式,实际 %d: %+v", len(methods), methods)
}
if methods[0].Type != "3" || methods[0].Name != "短信验证码" || methods[1].Type != "11" || methods[1].Name != "邮箱验证" {
t.Errorf("多方式弹窗解析错误: %+v", methods)
}
}
func TestParseMFAAuthMethodsDedupeAcrossLayouts(t *testing.T) {
// 同一方式同时出现在 Tab 与弹窗时应去重(保留先出现项)
page := `<html><body>
<div class="reauth-tab-item" id="tab_3" data-type="3"></div>
<div class="other_type_button" id="3">短信验证码</div>
</body></html>`
methods := parseMFAAuthMethods(page)
if len(methods) != 1 || methods[0].Type != "3" {
t.Fatalf("跨布局重复方式应去重,实际: %+v", methods)
}
}
func TestParseMFAAuthMethodsIgnoresComments(t *testing.T) {
page := `<html><body>
<!-- <div class="mobile-reauth-tab-item" data-type="9"><span class="mobile-reauth-tab-text">QQ扫码</span></div> -->
@@ -0,0 +1,40 @@
package cpdaily
import (
"context"
"net/http"
"strings"
)
// ========== 许昌学院(xcu)单独适配 ==========
// XcuSchoolId 许昌学院 tenant id
const XcuSchoolId = "xcu"
// xcuCasLoginEntry 计算许昌学院 CAS 登录入口:
// 校园网关的 /portal/login 会 302 到学校 CAS(authserver.xcu.edu.cn),并在 service 中
// 携带网关回调(/iap/loginSuccess?sessionToken=...);走完 CAS 账密 + MFA 后回调该地址,
// cookie 才能正确落在 xcu.campusphere.net 域(wec-* 任务接口所在域,activateSession 依赖)。
// WecloudUrl 缺失时回退用学校 ampUrl(仅保证能走通 CAS 账密 + MFA)。
func xcuCasLoginEntry(school *SchoolDetailInfo) string {
if school == nil {
return ""
}
if school.WecloudUrl != "" {
return strings.TrimSuffix(school.WecloudUrl, "/") + "/portal/login"
}
return school.AmpUrl
}
// xcuCasLogin 许昌学院强制走 CAS:
// 平台接口返回 joinType=CLOUD(IsIap()=true),但其 IAP 账密接口 /iap/doLogin 与通用
// IAP 密码加密格式不兼容(实测 RSA hex/base64 等多种编码均返回 FAIL_UPNOTMATCH);
// 实际登录链路为 IAP 入口 302 → 学校 CAS 账密 → MFA → 回调 /iap/loginSuccess 建立网关会话。
// 因此不走通用 IAP 流程,强制用 CAS 流程并从校园网关入口进入(详见 xcuCasLoginEntry)。
func xcuCasLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, *MFAContext, error) {
xcu := *school
if entry := xcuCasLoginEntry(school); entry != "" {
xcu.AmpUrl = entry
}
return casLogin(ctx, &xcu, username, password)
}
@@ -0,0 +1,69 @@
package cpdaily
import "testing"
// TestXcuCasLoginEntry 覆盖许昌学院 CAS 登录入口计算逻辑
func TestXcuCasLoginEntry(t *testing.T) {
cases := []struct {
name string
school *SchoolDetailInfo
want string
}{
{
name: "正常wecloud",
school: &SchoolDetailInfo{WecloudUrl: "https://xcu.campusphere.net"},
want: "https://xcu.campusphere.net/portal/login",
},
{
name: "wecloud带尾斜杠",
school: &SchoolDetailInfo{WecloudUrl: "https://xcu.campusphere.net/"},
want: "https://xcu.campusphere.net/portal/login",
},
{
name: "wecloud缺失回退ampUrl",
school: &SchoolDetailInfo{
AmpUrl: "https://casp.xcu.edu.cn/newmobile/client",
},
want: "https://casp.xcu.edu.cn/newmobile/client",
},
{
name: "均为空",
school: &SchoolDetailInfo{},
want: "",
},
{
name: "nil学校",
school: nil,
want: "",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := xcuCasLoginEntry(c.school); got != c.want {
t.Errorf("xcuCasLoginEntry() = %q, want %q", got, c.want)
}
})
}
}
// TestXcuCasLoginEntryOverridesAmpUrl 确认 xcuCasLogin 用网关入口覆盖 ampUrl 后走 CAS 流程
func TestXcuCasLoginEntryOverridesAmpUrl(t *testing.T) {
school := &SchoolDetailInfo{
ID: XcuSchoolId,
Name: "许昌学院",
JoinType: "CLOUD",
IdsUrl: "https://authserver.xcu.edu.cn/authserver",
WecloudUrl: "https://xcu.campusphere.net",
AmpUrl: "https://casp.xcu.edu.cn/newmobile/client",
}
// 模拟 xcuCasLogin 内部的入口覆盖(不发起网络请求)
clone := *school
clone.AmpUrl = xcuCasLoginEntry(school)
if got := getCasValidLoginUrl(&clone); got != "https://xcu.campusphere.net/portal/login" {
t.Errorf("getCasValidLoginUrl() = %q, want 网关入口", got)
}
// 原对象不被修改
if school.AmpUrl != "https://casp.xcu.edu.cn/newmobile/client" {
t.Errorf("原 school 对象 ampUrl 被意外修改: %q", school.AmpUrl)
}
}
@@ -171,6 +171,11 @@ func CpdailyLogin(ctx context.Context, school *SchoolDetailInfo, username, passw
cookies, err := iapLogin(ctx, school, username, password)
return cookies, nil, err
}
// 许昌学院:平台配置 joinType=CLOUD(IsIap=true),但 IAP 账密登录实际不兼容;
// 学校真实链路为 CAS 账密 + MFA + /iap/loginSuccess 回调,单独强制走 CAS(详见 login_xcu.go)
if school.ID == XcuSchoolId {
return xcuCasLogin(ctx, school, username, password)
}
if school.IsIap() || iapSchoolIds[school.ID] {
if !hasUsableLoginUrl(school) {
slog.Warn("IAP学校缺少登录地址", "school", school.Name, "schoolId", school.ID)