Files
likedream/internal/application/cpdaily/login/cas.go
T
2025-11-26 22:52:33 +08:00

572 lines
17 KiB
Go

package login
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"likedream-goframe/internal/application/cookiejar"
"likedream-goframe/internal/application/cpdaily/school"
"likedream-goframe/internal/application/httpclient"
"likedream-goframe/internal/common/cst"
"likedream-goframe/internal/common/errors"
"likedream-goframe/internal/framework/redis"
"likedream-goframe/internal/utils"
"net/http"
"net/url"
"regexp"
"slices"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
"github.com/avast/retry-go/v4"
"github.com/deatil/go-cryptobin/cryptobin/crypto"
"github.com/gogf/gf/v2/frame/g"
"github.com/samber/oops"
"github.com/tidwall/gjson"
"resty.dev/v3"
)
var (
saltRegexp = regexp.MustCompile(`var pwdDefaultEncryptSalt ?= ?"(.*?)"`)
errorMessageSelectors = []string{"#errorMsg", "#formErrorTip2", "#msg", "#dymsg", "#showErrorTip"}
)
type CpdailyCasLogin struct {
client *resty.Client
}
func NewCas(ctx context.Context) CpdailyLogin {
return &CpdailyCasLogin{
client: httpclient.NewCpdailyClient(),
}
}
func (c *CpdailyCasLogin) GetCookieJar() http.CookieJar {
return c.client.CookieJar()
}
func (c *CpdailyCasLogin) GetCookieString() string {
cookieJar := c.GetCookieJar()
serializableJar := cookieJar.(*cookiejar.PersistCookieJar)
return serializableJar.Serialize()
}
func (c *CpdailyCasLogin) GetClient() *resty.Client {
return c.client.SetRedirectPolicy(resty.NoRedirectPolicy())
}
func (c *CpdailyCasLogin) Login(ctx context.Context, schoolDetail *school.SchoolDetailInfo, username, password string) {
jar := cookiejar.NewPersistCookieJar()
c.client.SetCookieJar(jar).SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
// 获取合法的登录URL
loginUrl := getCasValidLoginUrl(schoolDetail)
// 请求登录页面并获取真正的登录URL
loginPageResponse, err := c.client.R().
WithContext(ctx).
Get(loginUrl)
utils.OkOrPanic(err)
defer loginPageResponse.Body.Close()
loginPageBytes, err := io.ReadAll(loginPageResponse.Body)
utils.OkOrPanic(err)
loginResponseUrl := loginPageResponse.RawResponse.Request.URL
// 关闭client的重定向
c.client.SetRedirectPolicy(resty.NoRedirectPolicy())
// 解析登录页面
loginPage, err := goquery.NewDocumentFromReader(bytes.NewReader(loginPageBytes))
utils.OkOrPanic(err)
// 判断登录类型
var (
loginType = 0
allInputs = map[string]string{}
casLoginFormNode = loginPage.Find("#casLoginForm").First()
loginFormIdNode = loginPage.Find("#loginFromId").First()
fm1Node = loginPage.Find("#fm1").First()
generalFormNode = loginPage.Find("form").First()
)
if casLoginFormNode.Length() != 0 {
loginType = 0
casLoginFormNode.Find("input").Each(func(_ int, s *goquery.Selection) {
var (
name = s.AttrOr("name", "")
value = s.AttrOr("value", "")
)
if name == "" || value == "" {
return
}
allInputs[name] = value
})
} else if loginFormIdNode.Length() != 0 {
loginType = 1
pwdFormIdNode := loginPage.Find("#pwdFromId").First()
pwdLoginDivNode := loginPage.Find("#pwdLoginDiv").First()
var inputs *goquery.Selection
if pwdFormIdNode.Length() != 0 {
inputs = pwdFormIdNode.Find("input")
} else if pwdLoginDivNode.Length() != 0 {
inputs = pwdLoginDivNode.Find("input")
} else {
loginFormIdNode.Find("input")
}
for _, input := range inputs.EachIter() {
name, exists := input.Attr("name")
if !exists {
continue
}
value, _ := input.Attr("value")
allInputs[name] = value
}
} else if fm1Node.Length() != 0 {
loginType = 2
inputs := loginPage.Find("input")
for _, input := range inputs.EachIter() {
name, exists := input.Attr("name")
if !exists {
continue
}
value, _ := input.Attr("value")
allInputs[name] = value
}
} else if generalFormNode.Length() != 0 {
loginType = -1
inputs := generalFormNode.Find("input")
for _, input := range inputs.EachIter() {
name, exists := input.Attr("name")
if !exists {
continue
}
value, _ := input.Attr("value")
allInputs[name] = value
}
} else {
utils.OkOrPanic(errors.NewBusinessError(503, "页面中未找到登录表单"), oops.With("schoolDetail", schoolDetail).
With("username", username).
With("password", password).
WithContext(ctx).
Wrap)
}
// 修改部分参数
if _, exists := allInputs["rememberMe"]; exists {
allInputs["rememberMe"] = "on"
}
salt := allInputs["encryptsalt"]
if salt == "" {
saltValue := loginPage.Find("#pwdEncryptSalt").AttrOr("value", "")
if saltValue != "" {
salt = saltValue
} else {
saltMatches := saltRegexp.FindStringSubmatch(string(loginPageBytes))
if len(saltMatches) > 1 {
salt = saltMatches[1]
}
}
}
allInputs["username"] = username
if salt == "" {
allInputs["password"] = password
} else {
allInputs["password"] = encryptCasPassword(password, salt)
}
// 声明需要滑块验证码的学校id
var SliderCaptchaIds = g.Cfg().MustGet(ctx, cst.ConfigSliderSchoolIds).Strings()
// []string{"1018764144934965", "sdhyxy", "hqdx"}
// 判断是否需要验证码
if isCasNeedCaptcha(ctx, loginResponseUrl, loginType, username, c.client) {
// 如果是滑块验证码
if slices.Contains(SliderCaptchaIds, schoolDetail.Id) {
e := retry.Do(func() error {
return utils.Catch(func() {
captchaUrl := getCasSliderCaptchaUrl(loginResponseUrl)
verifyUrl := getCasSliderVerifyUrl(loginResponseUrl)
ResolveCasSliderCaptcha(ctx, captchaUrl, verifyUrl, c.client)
})
}, retry.MaxDelay(15*time.Second), retry.Attempts(5), retry.Context(ctx))
if e != nil {
utils.OkOrPanic(errors.NewBusinessError(503, "滑块验证码解析失败").WithInnerError(e), oops.WithContext(ctx).Wrap)
}
} else {
// 解决常规图片验证码
captchaUrl := getCasCaptchaUrl(loginResponseUrl, loginType)
captchaResult := ResolveOcrCaptcha(ctx, captchaUrl, c.client)
switch loginType {
case 0:
allInputs["captchaResponse"] = captchaResult
default:
allInputs["captcha"] = captchaResult
}
}
}
loginPostResponse, err := c.client.R().
SetFormData(allInputs).
WithContext(ctx).
Post(loginResponseUrl.String())
utils.OkOrPanic(err, func(err error) error {
return oops.
WithContext(ctx).
With("loginResponseUrl", loginResponseUrl).
With("allInputs", allInputs).
Wrap(err)
})
defer loginPostResponse.Body.Close()
switch loginPostResponse.StatusCode() {
case 302:
handleCasLoginSuccess(ctx, loginPostResponse, schoolDetail, username, password, c.client)
case 200:
fallthrough
case 401:
doc, err := goquery.NewDocumentFromReader(loginPostResponse.Body)
utils.OkOrPanic(err, func(err error) error {
return oops.
WithContext(ctx).
With("loginResponseUrl", loginResponseUrl).
With("allInputs", allInputs).
Wrap(err)
})
if isCasNeedKickOut(doc) {
kickoutResponse := submitCasKickOut(ctx, loginResponseUrl.String(), doc, c.client)
handleCasLoginSuccess(ctx, kickoutResponse, schoolDetail, username, password, c.client)
return
}
message := getCasErrorMessage(doc)
utils.OkOrPanic(errors.NewBusinessError(503, fmt.Sprintf("今日校园登录失败: %s", message)), func(err error) error {
return oops.
WithContext(ctx).
With("loginResponseUrl", loginResponseUrl).
With("allInputs", allInputs).
With("errorMessage", message).
Wrap(errors.NewBusinessError(503, message))
})
}
}
func (c *CpdailyCasLogin) ConfirmAdd(ctx context.Context, sc *school.SchoolDetailInfo, username, password, key, code string) {
// 取出redis信息
cookieCmd := redis.GetClient().Get(ctx, key+":cookie")
cookie, err := cookieCmd.Result()
utils.OkOrPanic(err)
cookieJar := cookiejar.NewPersistCookieJar()
cookieJar.Deserialize(cookie)
hostCmd := redis.GetClient().Get(ctx, key+":host")
host, err := hostCmd.Result()
utils.OkOrPanic(err)
serviceCmd := redis.GetClient().Get(ctx, key+":service")
service, err := serviceCmd.Result()
utils.OkOrPanic(err)
// 提交确认添加
client := httpclient.NewCpdailyClient()
client.SetCookieJar(cookieJar)
post, err := client.R().SetFormData(map[string]string{
"service": service,
"reAuthType": "3", // 3.短信验证
"isMultifactor": "true",
"password": "",
"dynamicCode": code,
"uuid": "",
"answer1": "",
"answer2": "",
"otpCode": "",
"skipTmpReAuth": "true",
}).Post(host + "/authserver/reAuthCheck/reAuthSubmit.do")
utils.OkOrPanic(err)
result := utils.ReadGjsonResult(post.Body)
if result.Get("code").String() != "reAuth_success" {
utils.OkOrPanic(errors.NewBusinessError(503, fmt.Sprintf("今日校园登录失败: %s", result.Get("message").String())), func(err error) error {
return oops.
WithContext(ctx).
With("loginResponseUrl", host).
With("allInputs", "").
Wrap(errors.NewBusinessError(503, result.Get("msg").String()))
})
}
// 验证成功,跳转登录
respone2, _ := client.SetRedirectPolicy(resty.NoRedirectPolicy()).R().SetQueryParam("service", service).Get(host + "/authserver/login")
handleCasLoginSuccess(ctx, respone2, sc, username, password, client)
}
func getCasErrorMessage(doc *goquery.Document) string {
var message string
for _, selector := range errorMessageSelectors {
doc.Find(selector).Each(func(_ int, s *goquery.Selection) {
if msg := strings.TrimSpace(s.Text()); msg != "" {
message = msg
return
}
})
if message != "" {
break
}
}
if message == "" {
message = "登录失败,未找到错误信息"
}
return message
}
func isCasNeedKickOut(doc *goquery.Document) bool {
return doc.Find("#continue").Length() != 0
}
func ReAuthLoginConfirm(ctx context.Context, key string, dynamicCode string) {
}
func handleCasLoginSuccess(ctx context.Context, response *resty.Response, schoolInfo *school.SchoolDetailInfo, username string, password string, client *resty.Client) {
jumpUrl, err := response.RawResponse.Location()
utils.OkOrPanic(err)
client.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
verifyResponse, err := client.R().
WithContext(ctx).
SetHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36").
Get(jumpUrl.String())
utils.OkOrPanic(err, func(err error) error {
return oops.
WithContext(ctx).
With("jumpUrl", jumpUrl).
With("schoolDetail", schoolInfo).
Wrap(err)
})
defer verifyResponse.Body.Close()
statusCode := verifyResponse.StatusCode()
// 如果需要多因子认证
if strings.Contains(jumpUrl.String(), "reAuthLoginView.do") {
serviceStr := jumpUrl.Query().Get("service")
host := strings.TrimSuffix(jumpUrl.String(), jumpUrl.RequestURI())
SendDynamicCodeByPhone(ctx, username, host, client)
jar := client.CookieJar()
serializableJar := jar.(*cookiejar.PersistCookieJar)
key := cst.RedisCpUserLogin + schoolInfo.Id + username
schoolInfoStr, _ := json.Marshal(schoolInfo)
redis.GetClient().Set(ctx, key+":cookie", serializableJar.Serialize(), time.Duration(cst.RedisUserInfoExpire)*time.Second)
redis.GetClient().Set(ctx, key+":service", serviceStr, time.Duration(cst.RedisUserInfoExpire)*time.Second)
redis.GetClient().Set(ctx, key+":school", string(schoolInfoStr), time.Duration(cst.RedisUserInfoExpire)*time.Second)
redis.GetClient().Set(ctx, key+":host", host, time.Duration(cst.RedisUserInfoExpire)*time.Second)
redis.GetClient().Set(ctx, key+":username", username, time.Duration(cst.RedisUserInfoExpire)*time.Second)
redis.GetClient().Set(ctx, key+":password", password, time.Duration(cst.RedisUserInfoExpire)*time.Second)
utils.OkOrPanic(errors.NewBusinessError(201, "多因子验证码认证").WithData(key))
// 否则应该是登录成功
} else if statusCode == 200 || statusCode == 404 {
all, _ := io.ReadAll(verifyResponse.Body)
if strings.Contains(string(all), "完善资料") || strings.Contains(string(all), "为了你的帐号安全") {
utils.OkOrPanic(errors.NewBusinessError(503, "请手动学工号登录完善信息,修改密码").WithData(schoolInfo.WecloudUrl))
}
ActivateSession(ctx, schoolInfo.WecloudUrl, client)
} else {
utils.OkOrPanic(errors.NewBusinessError(503, "未知错误导致今日校园登录失败"),
func(err error) error {
return oops.
WithContext(ctx).
With("jumpUrl", jumpUrl).
With("schoolDetail", schoolInfo).
Wrap(err)
})
}
}
func submitCasKickOut(ctx context.Context, requestUrl string, doc *goquery.Document, client *resty.Client) *resty.Response {
submitForm := map[string]string{}
kickoutForm := doc.Find("#continue").First()
inputs := kickoutForm.Find("input")
for _, input := range inputs.EachIter() {
name, exists := input.Attr("name")
if !exists {
continue
}
value, _ := input.Attr("value")
submitForm[name] = value
}
client.SetRedirectPolicy(resty.NoRedirectPolicy())
response, err := client.R().
SetFormData(submitForm).
WithContext(ctx).
Post(requestUrl)
utils.OkOrPanic(err, func(err error) error {
return oops.
WithContext(ctx).
With("requestUrl", requestUrl).
With("submitForm", submitForm).
Wrap(err)
})
statusCode := response.StatusCode()
if statusCode == 302 || statusCode == 308 {
return response
}
utils.OkOrPanic(errors.NewBusinessError(503, fmt.Sprintf("今日校园登录失败,状态码: %d,请检查是否需要登出", statusCode)), func(err error) error {
return oops.
WithContext(ctx).
With("requestUrl", requestUrl).
With("submitForm", submitForm).
Wrap(err)
})
return nil
}
func getCasValidLoginUrl(info *school.SchoolDetailInfo) string {
// 特殊学校单独处理
// 甘肃农业大学 西安体育学院
ids := []string{"gsau", "ddf0639d-394d-48a2-a166-07ed84b7e6f0"}
if slices.Contains(ids, info.Id) {
return info.IdsUrl
}
if strings.Contains(info.AmpUrl, "campusphere") || strings.Contains(info.AmpUrl, "wec-portal-mobile") {
return info.AmpUrl
} else if strings.Contains(info.AmpUrl2, "campusphere") || strings.Contains(info.AmpUrl2, "wec-portal-mobile") {
return info.AmpUrl2
} else if strings.Contains(info.AmpUrl3, "campusphere") || strings.Contains(info.AmpUrl3, "wec-portal-mobile") {
return info.AmpUrl3
}
loginUrl := utils.GetFirstNotEmptyString(info.AmpUrl, info.AmpUrl2, info.AmpUrl3)
if loginUrl != "" {
return loginUrl
}
utils.OkOrPanic(oops.
With("schoolDetail", info).
Wrap(errors.NewBusinessError(503, "学校登录URL为空")))
panic("unreachable")
}
func encryptCasPassword(password, salt string) string {
const randStrLen = 64
const randIvLen = 16
randomIv, randomStr := utils.GenerateRandomString(randIvLen), utils.GenerateRandomString(randStrLen)
cryptoResult := crypto.FromString(randomStr + password).
SetKey(salt).
SetIv(randomIv).
Aes().
CBC().
PKCS7Padding().
Encrypt()
utils.OkOrPanic(cryptoResult.Error(), func(err error) error {
return oops.With("password", password).
With("salt", salt).
With("randomIv", randomIv).
With("randomStr", randomStr).
WithContext(context.Background()).
Wrap(err)
})
return cryptoResult.ToBase64String()
}
func isCasNeedCaptcha(ctx context.Context, url *url.URL, loginType int, username string, client *resty.Client) bool {
var requestURL string
switch loginType {
case 0:
requestURL = fmt.Sprintf("%s://%s/authserver/needCaptcha.html", url.Scheme, url.Host)
default:
requestURL = fmt.Sprintf("%s://%s/authserver/checkNeedCaptcha.htl", url.Scheme, url.Host)
}
captchaResponse, err := client.R().
SetQueryParam("username", username).
WithContext(ctx).
Get(requestURL)
utils.OkOrPanic(oops.With().
With("requestURL", requestURL).
With("username", username).
WithContext(ctx).
Wrap(err))
defer captchaResponse.Body.Close()
if captchaResponse.IsError() {
utils.OkOrPanic(oops.With().
With("requestURL", requestURL).
With("username", username).
WithContext(ctx).
Wrap(errors.NewBusinessError(503, "请求验证码失败")))
}
responseBytes, err := io.ReadAll(captchaResponse.Body)
utils.OkOrPanic(err)
switch loginType {
case 0:
return strings.ToLower(strings.TrimSpace(string(responseBytes))) != "false"
default:
responseJSON := gjson.ParseBytes(responseBytes)
return responseJSON.Get("isNeed").Bool()
}
}
func getCasCaptchaUrl(url *url.URL, loginType int) string {
switch loginType {
case 0:
return fmt.Sprintf("%s://%s/authserver/captcha.html", url.Scheme, url.Host)
default:
return fmt.Sprintf("%s://%s/authserver/getCaptcha.htl", url.Scheme, url.Host)
}
}
func getCasSliderCaptchaUrl(url *url.URL) string {
// /authserver/common/openSliderCaptcha.htl?_=1762873725346
return fmt.Sprintf("%s://%s/authserver/common/openSliderCaptcha.htl", url.Scheme, url.Host)
}
func getCasSliderVerifyUrl(url *url.URL) string {
return fmt.Sprintf("%s://%s/authserver/common/verifySliderCaptcha.htl", url.Scheme, url.Host)
}