Files
cpdaily_xiaozhushou/backed/internal/service/cpdaily/login_hfut.go
T
becomer 27157a7b39
ci/woodpecker/tag/build Pipeline was successful
合肥工业大学登录适配 + 人脸复核等待序列改为 0/1/2/3 秒
合肥工业大学(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 秒。
2026-10-16 22:54:06 +08:00

208 lines
7.0 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"
"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 ""
}