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
ci/woodpecker/tag/build Pipeline was successful
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user