558 lines
16 KiB
Go
558 lines
16 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/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.Cpdaily.Clone(ctx),
|
|
}
|
|
}
|
|
|
|
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(oops.With("schoolDetail", schoolDetail).
|
|
With("username", username).
|
|
With("password", password).
|
|
WithContext(ctx).
|
|
Wrap(errors.NewBusinessError(503, "页面中未找到登录表单")))
|
|
}
|
|
|
|
// 修改部分参数
|
|
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)
|
|
}
|
|
var SliderCaptchaIds = []string{"1018764144934965"}
|
|
// 判断是否需要验证码
|
|
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.Cpdaily.Clone(ctx)
|
|
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 {
|
|
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 {
|
|
if strings.Contains(info.AmpUrl, "campusphere") {
|
|
return info.AmpUrl
|
|
} else if strings.Contains(info.AmpUrl2, "campusphere") {
|
|
return info.AmpUrl2
|
|
} else if strings.Contains(info.AmpUrl3, "campusphere") {
|
|
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)
|
|
}
|