2e2c161770
- backed/: Go Fiber backend with cpdaily integration - frondend/: Vue 3 + Vite frontend - Dockerfile: multi-stage build (Node → Go → Alpine) - deploy/docker-compose.yml: production compose file - .woodpecker/build.yml: CI/CD with blue-green zero-downtime deploy - /health endpoint for deployment health checks - Port 8884/8885 blue-green swap via 1Panel OpenResty
330 lines
9.9 KiB
Go
330 lines
9.9 KiB
Go
package cpdaily
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/tls"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/PuerkitoBio/goquery"
|
|
"github.com/go-resty/resty/v2"
|
|
)
|
|
|
|
// ========== CAS 登录 ==========
|
|
|
|
// sessionClient 包装 resty.Client 并保存 cookie jar 引用
|
|
type sessionClient struct {
|
|
*resty.Client
|
|
jar *simpleCookieJar
|
|
}
|
|
|
|
func newSessionClient() *sessionClient {
|
|
jar := newCookieJar()
|
|
client := resty.New().
|
|
SetTimeout(30*time.Second).
|
|
SetTLSClientConfig(&tls.Config{
|
|
InsecureSkipVerify: true,
|
|
MinVersion: tls.VersionTLS10,
|
|
}).
|
|
SetHeader("User-Agent", "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1")
|
|
client.SetCookieJar(jar)
|
|
client.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
|
|
|
|
return &sessionClient{
|
|
Client: client,
|
|
jar: jar,
|
|
}
|
|
}
|
|
|
|
func casLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, error) {
|
|
sc := newSessionClient()
|
|
|
|
// 1. 获取合法的登录 URL
|
|
loginUrl := getCasValidLoginUrl(school)
|
|
slog.Info("CAS登录", "loginUrl", loginUrl, "school", school.Name, "idsUrl", school.IdsUrl)
|
|
|
|
// 2. GET 登录页面 (使用带60s timeout的独立context)
|
|
loginCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
defer cancel()
|
|
|
|
loginPageResp, err := sc.R().SetContext(loginCtx).Get(loginUrl)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("请求登录页面失败: %w", err)
|
|
}
|
|
|
|
loginPageBytes := loginPageResp.Body()
|
|
loginResponseUrl := loginPageResp.RawResponse.Request.URL
|
|
slog.Info("CAS登录页", "finalUrl", loginResponseUrl.String(), "bodyLen", len(loginPageBytes))
|
|
|
|
// 3. 禁用自动重定向
|
|
sc.SetRedirectPolicy(resty.NoRedirectPolicy())
|
|
|
|
// 4. 解析表单
|
|
allInputs := parseLoginForm(loginPageBytes, username, password)
|
|
slog.Info("CAS表单解析", "inputs", fmt.Sprintf("%v", allInputs))
|
|
|
|
// 5. 判断是否需要验证码
|
|
needCaptcha, loginType := detectCaptcha(ctx, loginResponseUrl, username, sc.Client)
|
|
slog.Info("CAS验证码检测", "needCaptcha", needCaptcha, "loginType", loginType)
|
|
if needCaptcha {
|
|
captchaCode := resolveOcrCaptcha(ctx, loginResponseUrl, loginType, sc.Client)
|
|
slog.Info("CAS验证码OCR", "code", captchaCode)
|
|
switch loginType {
|
|
case 0:
|
|
allInputs["captchaResponse"] = captchaCode
|
|
default:
|
|
allInputs["captcha"] = captchaCode
|
|
}
|
|
}
|
|
|
|
// 6. POST 提交登录
|
|
loginResp, err := sc.R().
|
|
SetFormData(allInputs).
|
|
SetContext(loginCtx).
|
|
Post(loginResponseUrl.String())
|
|
if err != nil {
|
|
// NoRedirectPolicy 下 302 会返回 http.ErrUseLastResponse,此时 loginResp 仍然有效
|
|
if loginResp != nil && loginResp.StatusCode() == 302 {
|
|
slog.Info("CAS登录重定向(登录成功)", "location", loginResp.Header().Get("Location"))
|
|
} else {
|
|
return nil, fmt.Errorf("提交登录失败: %w", err)
|
|
}
|
|
}
|
|
|
|
slog.Info("CAS登录响应", "statusCode", loginResp.StatusCode())
|
|
|
|
// 7. 处理响应
|
|
switch loginResp.StatusCode() {
|
|
case 302:
|
|
slog.Info("CAS登录成功", "redirect", loginResp.Header().Get("Location"))
|
|
return handleCasLoginSuccess(ctx, loginResp, school, sc)
|
|
case 200, 401:
|
|
body := loginResp.Body()
|
|
slog.Info("CAS登录失败响应体", "body", string(body[:min(len(body), 2000)]))
|
|
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(body))
|
|
if err == nil && isCasNeedKickOut(doc) {
|
|
slog.Info("CAS需要踢下线")
|
|
kickResp := submitCasKickOut(ctx, loginResponseUrl.String(), doc, sc.Client)
|
|
return handleCasLoginSuccess(ctx, kickResp, school, sc)
|
|
}
|
|
msg := getCasErrorMessage(doc)
|
|
if msg == "" {
|
|
msg = "登录失败,请检查账号密码"
|
|
}
|
|
return nil, fmt.Errorf("%s", msg)
|
|
default:
|
|
return nil, fmt.Errorf("登录返回异常状态码: %d", loginResp.StatusCode())
|
|
}
|
|
}
|
|
|
|
// 获取 CAS 合法登录 URL
|
|
func getCasValidLoginUrl(school *SchoolDetailInfo) string {
|
|
if school.IdsUrl != "" {
|
|
if strings.Contains(school.IdsUrl, "campusphere") || strings.Contains(school.IdsUrl, "wec-portal-mobile") {
|
|
return school.IdsUrl
|
|
}
|
|
}
|
|
for _, u := range []string{school.AmpUrl, school.AmpUrl2, school.AmpUrl3} {
|
|
if u != "" {
|
|
return u
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// 解析登录表单
|
|
func parseLoginForm(page []byte, username, password string) map[string]string {
|
|
inputs := make(map[string]string)
|
|
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(page))
|
|
if err != nil {
|
|
return inputs
|
|
}
|
|
|
|
// 优先选择密码登录表单(pwdFromId),其次是其他已知CAS表单ID
|
|
selectors := []string{"#pwdFromId", "#phoneFromId", "#casLoginForm", "#loginFromId", "#fm1", "form"}
|
|
var formSel *goquery.Selection
|
|
for _, sel := range selectors {
|
|
formSel = doc.Find(sel)
|
|
if formSel.Length() > 0 {
|
|
slog.Info("CAS表单选择", "selector", sel, "count", formSel.Length())
|
|
break
|
|
}
|
|
}
|
|
|
|
formSel.Find("input").Each(func(_ int, s *goquery.Selection) {
|
|
name, _ := s.Attr("name")
|
|
value, _ := s.Attr("value")
|
|
if name != "" {
|
|
inputs[name] = value
|
|
}
|
|
})
|
|
|
|
inputs["username"] = username
|
|
|
|
// 清除表单中不必要或可能干扰的字段
|
|
delete(inputs, "userPassword")
|
|
delete(inputs, "passwordText")
|
|
|
|
salt := inputs["encryptsalt"]
|
|
if salt == "" {
|
|
salt, _ = doc.Find("#pwdEncryptSalt").Attr("value")
|
|
}
|
|
if salt == "" {
|
|
matches := saltRegexp.FindStringSubmatch(string(page))
|
|
if len(matches) > 1 {
|
|
salt = matches[1]
|
|
}
|
|
}
|
|
|
|
if salt != "" {
|
|
slog.Info("CAS密码加密", "salt", salt)
|
|
inputs["password"] = EncryptCasPassword(password, salt)
|
|
} else {
|
|
slog.Info("CAS密码未加密")
|
|
inputs["password"] = password
|
|
}
|
|
|
|
if _, exists := inputs["rememberMe"]; exists {
|
|
inputs["rememberMe"] = "on"
|
|
}
|
|
|
|
return inputs
|
|
}
|
|
|
|
// 检测是否需要验证码
|
|
func detectCaptcha(ctx context.Context, loginUrl *url.URL, username string, client *resty.Client) (bool, int) {
|
|
checkUrl := fmt.Sprintf("%s://%s/authserver/needCaptcha.html?username=%s",
|
|
loginUrl.Scheme, loginUrl.Host, username)
|
|
resp, err := client.R().SetContext(ctx).Get(checkUrl)
|
|
if err == nil {
|
|
bodyStr := strings.TrimSpace(string(resp.Body()))
|
|
// 只当响应体是简单文本(不含HTML标签)时才判断,否则可能是非法请求页面
|
|
if !strings.Contains(bodyStr, "<!DOCTYPE") && !strings.Contains(bodyStr, "<html") {
|
|
slog.Info("CAS检测验证码", "url", checkUrl, "body", bodyStr)
|
|
if strings.Contains(bodyStr, "true") {
|
|
return true, 0
|
|
}
|
|
return false, 0
|
|
}
|
|
slog.Info("CAS检测验证码-非法请求", "url", checkUrl, "bodyLen", len(bodyStr))
|
|
}
|
|
|
|
checkUrl2 := fmt.Sprintf("%s://%s/authserver/checkNeedCaptcha.htl?username=%s",
|
|
loginUrl.Scheme, loginUrl.Host, username)
|
|
resp2, err := client.R().SetContext(ctx).Get(checkUrl2)
|
|
if err == nil {
|
|
bodyStr := string(resp2.Body())
|
|
// 同样检查是否返回了非法请求页面
|
|
if !strings.Contains(bodyStr, "<!DOCTYPE") && !strings.Contains(bodyStr, "<html") {
|
|
slog.Info("CAS检测验证码-方法2", "url", checkUrl2, "body", bodyStr)
|
|
if strings.Contains(bodyStr, `"isNeed":true`) {
|
|
return true, 1
|
|
}
|
|
}
|
|
}
|
|
return false, 0
|
|
}
|
|
|
|
// 处理 CAS 验证码
|
|
func resolveOcrCaptcha(ctx context.Context, loginUrl *url.URL, loginType int, client *resty.Client) string {
|
|
var captchaUrl string
|
|
if loginType == 0 {
|
|
captchaUrl = fmt.Sprintf("%s://%s/authserver/captcha", loginUrl.Scheme, loginUrl.Host)
|
|
} else {
|
|
// loginType == 1: 使用 getCaptcha.htl(安徽师范大学等学校使用此路径)
|
|
captchaUrl = fmt.Sprintf("%s://%s/authserver/getCaptcha.htl", loginUrl.Scheme, loginUrl.Host)
|
|
}
|
|
|
|
imgResp, err := client.R().SetContext(ctx).Get(captchaUrl)
|
|
if err != nil {
|
|
slog.Info("CAS验证码获取失败", "url", captchaUrl, "err", err)
|
|
return ""
|
|
}
|
|
imgData := imgResp.Body()
|
|
slog.Info("CAS验证码获取成功", "url", captchaUrl, "size", len(imgData))
|
|
return captchaOCR(imgData)
|
|
}
|
|
|
|
// 处理 CAS 登录成功
|
|
func handleCasLoginSuccess(ctx context.Context, resp *resty.Response, school *SchoolDetailInfo, sc *sessionClient) ([]*http.Cookie, error) {
|
|
jumpUrl, err := resp.RawResponse.Location()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("获取重定向地址失败: %w", err)
|
|
}
|
|
|
|
sc.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
|
|
verifyResp, err := sc.R().SetContext(ctx).Get(jumpUrl.String())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("跟随重定向失败: %w", err)
|
|
}
|
|
_ = verifyResp // body不需要读取
|
|
|
|
activateSession(ctx, school.WecloudUrl, sc.Client)
|
|
|
|
// 从 cookie jar 获取所有域名的 cookies
|
|
cookies := sc.jar.AllCookies()
|
|
return cookies, nil
|
|
}
|
|
|
|
// 激活 Session
|
|
func activateSession(ctx context.Context, host string, client *resty.Client) {
|
|
if host == "" {
|
|
return
|
|
}
|
|
host = strings.TrimSuffix(host, "/")
|
|
client.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
|
|
|
|
client.R().SetContext(ctx).
|
|
Get(host + "/wec-im-group/group/groupMember/checkImInitFlag")
|
|
|
|
client.R().SetContext(ctx).
|
|
SetBody(map[string]interface{}{}).
|
|
Post(host + "/wec-counselor-attendance-apps/student/attendance/getStuSignInfosByWeekMonth")
|
|
|
|
client.SetRedirectPolicy(resty.NoRedirectPolicy())
|
|
}
|
|
|
|
// ========== 辅助函数 ==========
|
|
|
|
func isCasNeedKickOut(doc *goquery.Document) bool {
|
|
return doc.Find("#continue").Length() > 0 || strings.Contains(doc.Text(), "踢下线")
|
|
}
|
|
|
|
func submitCasKickOut(ctx context.Context, loginUrl string, doc *goquery.Document, client *resty.Client) *resty.Response {
|
|
inputs := map[string]string{}
|
|
doc.Find("#continue input").Each(func(_ int, s *goquery.Selection) {
|
|
name, _ := s.Attr("name")
|
|
value, _ := s.Attr("value")
|
|
if name != "" {
|
|
inputs[name] = value
|
|
}
|
|
})
|
|
if _, exists := inputs["continue"]; !exists {
|
|
inputs["continue"] = "确认继续"
|
|
}
|
|
resp, _ := client.R().SetContext(ctx).SetFormData(inputs).Post(loginUrl)
|
|
return resp
|
|
}
|
|
|
|
func getCasErrorMessage(doc *goquery.Document) string {
|
|
if doc == nil {
|
|
return "登录失败"
|
|
}
|
|
selectors := []string{
|
|
"#msg", "#loginError", ".login-error",
|
|
".error", "#error", "p.error",
|
|
}
|
|
for _, sel := range selectors {
|
|
msg := strings.TrimSpace(doc.Find(sel).Text())
|
|
if msg != "" {
|
|
return msg
|
|
}
|
|
}
|
|
return ""
|
|
}
|