27157a7b39
ci/woodpecker/tag/build Pipeline was successful
合肥工业大学(school_id 46a6ac8d-...):平台配置 joinType=CLOUD、idsUrl=hfut.campusphere.net/iap, 但网关 IAP 账密接口不兼容,登录报「账号或密码错误」。实测真实链路为 /iap 302 → cas.hfut.edu.cn/cas/login(标准 Apereo CAS,表单 id=fm1): - 密码需 AES-ECB/PKCS7 加密,key 来自 /cas/checkInitParams 下发的 LOGIN_FLAVORING cookie (通用 casLogin 从不调该接口,拿不到 key,password 加密错误导致误报密码错误); - 提交账密后 302 回调 /iap/loginSuccess?sessionToken=...&ticket=ST-... 建立网关会话(MOD_AUTH_CAS)。 新增 login_hfut.go 单独适配(hfutCasLogin/hfutEncryptPassword/hfutCasEntry),并在 CpdailyLogin 分发中接入;已用真实账号实测登录成功。 另:人脸识别复核轮询等待序列由 0/1/2(累计3s)改为 0/1/2/3 秒,逐次等待递增到 3 秒。
208 lines
7.0 KiB
Go
208 lines
7.0 KiB
Go
package cpdaily
|
||
|
||
import (
|
||
"context"
|
||
"crypto/aes"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/http"
|
||
"regexp"
|
||
"strings"
|
||
|
||
"github.com/go-resty/resty/v2"
|
||
)
|
||
|
||
// ========== 合肥工业大学(hfut)单独适配 ==========
|
||
|
||
// HfutSchoolId 合肥工业大学 tenant id
|
||
const HfutSchoolId = "46a6ac8d-0795-4759-b777-c9aa0148b959"
|
||
|
||
// hfutCasEntry 计算合肥工业大学 CAS 登录入口:
|
||
// 网关域 hfut.campusphere.net 的 /iap(school.IdsUrl)会 302 到学校 CAS
|
||
// (cas.hfut.edu.cn/cas/login),service 参数携带网关回调
|
||
// /iap/loginSuccess?sessionToken=...;走完 CAS 账密后回调该地址,
|
||
// cookie 才能正确落在 hfut.campusphere.net 域(wec-* 任务接口所在域)。
|
||
func hfutCasEntry(school *SchoolDetailInfo) string {
|
||
if school == nil {
|
||
return ""
|
||
}
|
||
if school.IdsUrl != "" && strings.Contains(school.IdsUrl, "/iap") {
|
||
return school.IdsUrl
|
||
}
|
||
if school.WecloudUrl != "" {
|
||
return strings.TrimSuffix(school.WecloudUrl, "/") + "/iap"
|
||
}
|
||
return school.AmpUrl
|
||
}
|
||
|
||
// hfutEncryptPassword 合肥工业大学 CAS 密码加密:
|
||
// 前端 encryptionPwd() 用 AES-ECB/PKCS7,key = cookie LOGIN_FLAVORING(该 cookie 由
|
||
// /cas/checkInitParams 下发),明文密码 UTF-8 编码后加密,输出 Base64。
|
||
func hfutEncryptPassword(password, key string) (string, error) {
|
||
if key == "" {
|
||
return "", fmt.Errorf("缺少密码加密密钥(LOGIN_FLAVORING)")
|
||
}
|
||
block, err := aes.NewCipher([]byte(key))
|
||
if err != nil {
|
||
return "", fmt.Errorf("初始化 AES 失败: %w", err)
|
||
}
|
||
plain := pkcs7Pad([]byte(password), block.BlockSize())
|
||
encrypted := make([]byte, len(plain))
|
||
// AES-ECB:逐块独立加密
|
||
for i := 0; i < len(plain); i += block.BlockSize() {
|
||
block.Encrypt(encrypted[i:i+block.BlockSize()], plain[i:i+block.BlockSize()])
|
||
}
|
||
return base64.StdEncoding.EncodeToString(encrypted), nil
|
||
}
|
||
|
||
var hfutExecutionRe = regexp.MustCompile(`name="execution"\s+value="([^"]*)"`)
|
||
|
||
// hfutCasLogin 合肥工业大学强制走其 CAS:
|
||
// 平台接口返回 joinType=CLOUD(IsIap()=true),idsUrl=hfut.campusphere.net/iap,
|
||
// 但网关 IAP 账密接口与本链路不兼容;实际链路为 /iap 302 → cas.hfut.edu.cn/cas/login
|
||
// (标准 Apereo CAS,表单 id=fm1)→ 密码 AES-ECB(LOGIN_FLAVORING) 加密后 POST 登录
|
||
// → 302 回调 /iap/loginSuccess?sessionToken=...&ticket=ST-... 建立网关会话(MOD_AUTH_CAS)。
|
||
//
|
||
// 关键差异(通用 casLogin 会报"账号或密码错误"的原因):
|
||
// 1. 密码必须 AES-ECB/PKCS7 加密,key 来自 /cas/checkInitParams 下发的 LOGIN_FLAVORING cookie;
|
||
// 2. CAS 有自研 /cas/checkInitParams 初始化接口,通用流程从不调用,故拿不到加密 key。
|
||
func hfutCasLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, *MFAContext, error) {
|
||
entry := hfutCasEntry(school)
|
||
if entry == "" {
|
||
return nil, nil, fmt.Errorf("合肥工业大学登录入口缺失")
|
||
}
|
||
|
||
sc := newSessionClient()
|
||
|
||
// 1. 从网关 /iap 进入,302 到 CAS 登录页(带 service 回调地址)
|
||
pageResp, err := sc.R().SetContext(ctx).Get(entry)
|
||
if err != nil {
|
||
return nil, nil, fmt.Errorf("请求登录入口失败: %w", err)
|
||
}
|
||
loginURL := pageResp.RawResponse.Request.URL
|
||
if loginURL == nil || !strings.Contains(loginURL.String(), "/cas/login") {
|
||
// 未落到 CAS 登录页(网关异常),退回通用 CAS 流程兜底
|
||
return casLogin(ctx, school, username, password)
|
||
}
|
||
loginURLStr := loginURL.String()
|
||
pageBody := readBody(pageResp)
|
||
casBase := fmt.Sprintf("%s://%s/cas", loginURL.Scheme, loginURL.Host)
|
||
|
||
// 2. 调 checkInitParams 获取密码加密密钥(LOGIN_FLAVORING cookie)
|
||
if _, cerr := sc.R().SetContext(ctx).Get(casBase + "/checkInitParams"); cerr != nil {
|
||
return nil, nil, fmt.Errorf("获取登录初始化参数失败: %w", cerr)
|
||
}
|
||
flavoring := ""
|
||
for _, ck := range sc.jar.AllCookies() {
|
||
if ck.Name == "LOGIN_FLAVORING" {
|
||
flavoring = ck.Value
|
||
break
|
||
}
|
||
}
|
||
if flavoring == "" {
|
||
return nil, nil, fmt.Errorf("获取密码加密密钥失败(LOGIN_FLAVORING 缺失)")
|
||
}
|
||
encPwd, err := hfutEncryptPassword(password, flavoring)
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
|
||
// 3. 解析表单 execution(Apereo CAS CSRF token)
|
||
execution := ""
|
||
if m := hfutExecutionRe.FindSubmatch(pageBody); len(m) > 1 {
|
||
execution = string(m[1])
|
||
}
|
||
|
||
submitData := map[string]string{
|
||
"username": username,
|
||
"password": encPwd,
|
||
"execution": execution,
|
||
"_eventId": "submit",
|
||
"geolocation": "",
|
||
}
|
||
|
||
// 4. 提交登录。禁用自动重定向以捕获 302 回调地址与 200 失败页。
|
||
sc.SetRedirectPolicy(resty.NoRedirectPolicy())
|
||
loginResp, err := sc.R().SetContext(ctx).SetFormData(submitData).Post(loginURLStr)
|
||
if err != nil && (loginResp == nil || loginResp.StatusCode() != 302) {
|
||
return nil, nil, fmt.Errorf("提交登录失败: %w", err)
|
||
}
|
||
|
||
switch loginResp.StatusCode() {
|
||
case 302:
|
||
location := loginResp.Header().Get("Location")
|
||
// 多因子认证(如有)
|
||
if strings.Contains(location, "isMultifactor=true") || strings.Contains(location, "reAuthCheck") {
|
||
mfa, merr := handleMFATriggered(ctx, loginResp, school, sc)
|
||
if merr != nil {
|
||
return nil, nil, merr
|
||
}
|
||
return nil, mfa, ErrMFARequired
|
||
}
|
||
if location == "" {
|
||
return nil, nil, fmt.Errorf("登录失败,未获取到回调地址")
|
||
}
|
||
// 跟随回调建立网关会话
|
||
sc.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
|
||
if _, cerr := sc.R().SetContext(ctx).Get(location); cerr != nil {
|
||
return nil, nil, fmt.Errorf("跟随登录回调失败: %w", cerr)
|
||
}
|
||
case 200:
|
||
// 登录失败:尝试识别错误信息(含验证码要求时重试)
|
||
body := readBody(loginResp)
|
||
msg := hfutCasError(body)
|
||
if msg == "" {
|
||
msg = "登录失败,请检查账号密码"
|
||
}
|
||
return nil, nil, fmt.Errorf("%s", msg)
|
||
default:
|
||
return nil, nil, fmt.Errorf("登录返回异常状态码: %d", loginResp.StatusCode())
|
||
}
|
||
|
||
sc.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
|
||
activateSession(ctx, school.WecloudUrl, sc.Client)
|
||
|
||
cookies := sc.jar.AllCookies()
|
||
hasMOD := false
|
||
for _, ck := range cookies {
|
||
if strings.HasPrefix(ck.Name, "MOD_") {
|
||
hasMOD = true
|
||
break
|
||
}
|
||
}
|
||
if !hasMOD {
|
||
return nil, nil, fmt.Errorf("登录失败,未获取到有效的认证信息")
|
||
}
|
||
return cookies, nil, nil
|
||
}
|
||
|
||
// hfutCasError 从 CAS 登录失败页提取可读错误信息(简化实现,够用即止)。
|
||
func hfutCasError(body []byte) string {
|
||
s := string(body)
|
||
// 常见错误容器:#msg / #errorMsg / 类 errMessage
|
||
for _, key := range []string{`id="msg"`, `id="errorMsg"`, `class="errMessage"`} {
|
||
if i := strings.Index(s, key); i >= 0 {
|
||
seg := s[i:]
|
||
if j := strings.Index(seg, ">"); j >= 0 {
|
||
rest := seg[j+1:]
|
||
if k := strings.Index(rest, "<"); k > 0 {
|
||
txt := strings.TrimSpace(rest[:k])
|
||
if txt != "" {
|
||
return txt
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// 兜底:JSON 错误体
|
||
var r struct {
|
||
Code int `json:"code"`
|
||
Msg string `json:"msg"`
|
||
}
|
||
if json.Unmarshal(body, &r) == nil && r.Msg != "" {
|
||
return r.Msg
|
||
}
|
||
return ""
|
||
}
|