Files
cpdaily_xiaozhushou/backed/internal/service/cpdaily/login_cas.go
T
becomer 2e2c161770 feat: init cpdaily-xiaozhushou with blue-green deploy
- 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
2026-07-03 15:37:55 +08:00

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 ""
}