✨ 适配南昌理工学院

This commit is contained in:
sugarBecomer
2025-11-13 00:17:49 +08:00
parent 22735aeaae
commit cb808f9c68
9 changed files with 502 additions and 30 deletions
+2 -2
View File
@@ -42,6 +42,6 @@ baseUrl = "http://s3.out"
bucket = "likedream"
[cpdaily]
appVersion = "5.7.0"
appVersion = "9.7.2"
systemVersion = "iOS 17"
calVersion = "3.0.0"
calVersion = "firstv"
@@ -0,0 +1,76 @@
package cryptoutil
import (
"crypto/md5"
"encoding/base64"
"encoding/hex"
"net/url"
"strings"
"testing"
"github.com/gogf/gf/v2/container/gvar"
)
func TestDesDecrypt(t *testing.T) {
decodeString, _ := base64.StdEncoding.DecodeString("X3/auV/FUP3Fj9iou6jNosFm9+6oiZ08pQlSt2qUlEhBEZXchcrXZJ96OsPIH4tfc6oRqdvlnObFx+uJfKA9oEuQQURvvDcDLz/1uAsQHXQIXNaW1Bwt9vdo4e1Sxzvt2sBxcHZXlsAPnxRw8UCM6DnoU4kHXF45A2i9wQpzH1cgKsoN/tWLRPYOBSMsPfbsch+wFgFpkQGRWNM9E0CVmpwZJWn946McO4HbMJngTeT5Df8YOUgpkFdccMKrJyPqE7UMOc56YLkPjb/wzmFyd1/HFCUbJwwdhOueH70GSpsH5LrWEhw7jvf2VBmnm669E2uvQjeCtPI1SvVuf7WP9Srp1BXpMtfK")
decrypt := DesDecrypt(decodeString, Cat)
str := "{\"lon\":\"113.045246\",\"lat\":\"28.077659\",\"model\":\"847b5b9c-2d97-41ec-8a5d-e9ac79da090d\",\"appVersion\":\"9.7.2\",\"userId\":\"430181200504150038\",\"systemName\":\"android\",\"deviceId\":\"09E46EC9-57C1-4724-BE53-5879CA80A2A0\",\"cpdailyVersion\":\"9.7.2\",\"systemVersion\":\"iOS 17\"}"
encrypt := DesEncrypt([]byte(str), Cat)
toString := base64.StdEncoding.EncodeToString(encrypt)
t.Log(toString)
t.Log(string(decrypt))
d2, _ := base64.StdEncoding.DecodeString("0u7hBd+6O11ZaGsT51FUAVfB72waqtTx5oQQLVEEAuCM29x8lQc41i9/dF6+9wjQNC05jdJTBmHlwFoLD09hfsnQWSyjl/ys124RUQL3r/Um5AwNdKiJE24YQsyQlP3ZamUIFlrNSxmn/DfZ/jR9oZhiVKZSlygA2LNLxtNOyk4rETfqKpZKFOnThbXy5Hf779jUDmPNwn1ZrdWN8UHm6wO+EahrrX60vC02VUHs7r3rMrDWZjBJ57eSgD0QwZabcIb/u9kx1AD8OAxINar3X+N+uAhfrxjHd6v6OVvnc2I9iPLEGNFRKZ94jq64hnATOSH6S/IR/QbDoxebWpGUXm3ALYZeoni09uyhG7IE5NtRw/OmqYtdknrs+c0qyOOBkvxPlqKo+SV4qq7xtrn9RWeZIigiyM8mVtdovN1T/x3DnHxd/AYoSS3/cGkg+2QHzWey/Xf5c4g0EvhaGlE0C7ejKIn5wp0XeHyvAjm4ZVyUhgTN2dZVCQ6ChxXOpLnVDWPNeeZ9vysqVl1ZSkQdCz20gy3p9RfYuArluQqjwIoWQe632Cc/Ep++99V5Tc9iIlLr93PfxFaJJfov3avLKw==")
d := AesDecryptForCat(d2)
t.Log(d)
form := map[string]*gvar.Var{
"appVersion": gvar.New("9.7.2"),
"bodyString": gvar.New("0u7hBd+6O11ZaGsT51FUAVfB72waqtTx5oQQLVEEAuCM29x8lQc41i9/dF6+9wjQNC05jdJTBmHlwFoLD09hfsnQWSyjl/ys124RUQL3r/Um5AwNdKiJE24YQsyQlP3ZamUIFlrNSxmn/DfZ/jR9oZhiVKZSlygA2LNLxtNOyk4rETfqKpZKFOnThbXy5Hf779jUDmPNwn1ZrdWN8UHm6wO+EahrrX60vC02VUHs7r3rMrDWZjBJ57eSgD0QwZabcIb/u9kx1AD8OAxINar3X+N+uAhfrxjHd6v6OVvnc2I9iPLEGNFRKZ94jq64hnATOSH6S/IR/QbDoxebWpGUXm3ALYZeoni09uyhG7IE5NtRw/OmqYtdknrs+c0qyOOBkvxPlqKo+SV4qq7xtrn9RWeZIigiyM8mVtdovN1T/x3DnHxd/AYoSS3/cGkg+2QHzWey/Xf5c4g0EvhaGlE0C7ejKIn5wp0XeHyvAjm4ZVyUhgTN2dZVCQ6ChxXOpLnVDWPNeeZ9vysqVl1ZSkQdCz20gy3p9RfYuArluQqjwIoWQe632Cc/Ep++99V5Tc9iIlLr93PfxFaJJfov3avLKw=="),
"calVersion": gvar.New("firstv"),
"deviceId": gvar.New("09E46EC9-57C1-4724-BE53-5879CA80A2A0"),
"lat": gvar.New("28.077659"),
"lon": gvar.New("113.045246"),
"model": gvar.New("847b5b9c-2d97-41ec-8a5d-e9ac79da090d"),
"systemName": gvar.New("android"),
"systemVersion": gvar.New("iOS 17"),
"userId": gvar.New("430181200504150038"),
"version": gvar.New("first_v4"),
}
sign := GetSubmitSign(form)
t.Log(sign)
}
const submitSignSalt = "hru89rwr87Nthifa" // 请根据实际情况替换为真实盐值
func GetSubmitSign(form map[string]*gvar.Var) string {
// 按照指定顺序构建哈希数据
keys := []string{
"appVersion",
"bodyString",
"deviceId",
"lat",
"lon",
"model",
"systemName",
"systemVersion",
"userId",
}
var sb strings.Builder
for _, key := range keys {
val := ""
if v, ok := form[key]; ok && v != nil {
val = v.String()
}
// 这里是 key=UrlEncode(key)&,注意是对 key 进行 url encode
sb.WriteString(key)
sb.WriteString("=")
sb.WriteString(url.QueryEscape(val))
sb.WriteString("&")
}
sb.WriteString(submitSignSalt)
// 计算MD5
hash := md5.Sum([]byte(sb.String()))
return hex.EncodeToString(hash[:])
}
@@ -0,0 +1,122 @@
package cryptoutil
import (
"crypto/rand"
"encoding/hex"
"fmt"
"math/big"
)
// RSAKeyPair RSA密钥对结构体
type RSAKeyPair struct {
n *big.Int // 模数
e *big.Int // 公钥指数
}
// NewRSAKeyPair 创建RSA密钥对
// exponent: 公钥指数的十六进制字符串,通常是 "10001" (65537)
// privateExponent: 私钥指数的十六进制字符串,加密时可以为空
// modulus: 模数的十六进制字符串
func NewRSAKeyPair(exponent, privateExponent, modulus string) (*RSAKeyPair, error) {
// 解析公钥指数
e := new(big.Int)
if _, ok := e.SetString(exponent, 16); !ok {
return nil, fmt.Errorf("invalid exponent: %s", exponent)
}
// 解析模数
n := new(big.Int)
if _, ok := n.SetString(modulus, 16); !ok {
return nil, fmt.Errorf("invalid modulus: %s", modulus)
}
return &RSAKeyPair{
n: n,
e: e,
}, nil
}
// Encrypt 使用公钥加密数据,返回十六进制字符串
func (k *RSAKeyPair) Encrypt(plaintext string) (string, error) {
ciphertext, err := k.EncryptBytes([]byte(plaintext))
if err != nil {
return "", err
}
// 返回十六进制编码的密文
return hex.EncodeToString(ciphertext), nil
}
// EncryptBytes 使用公钥加密字节数据,返回加密后的字节
// 使用 PKCS1v15 填充方式,兼容 JavaScript RSA 实现
func (k *RSAKeyPair) EncryptBytes(plaintext []byte) ([]byte, error) {
// 计算密钥长度(字节)
keySize := (k.n.BitLen() + 7) / 8
// PKCS1v15 填充
padded, err := pkcs1v15Pad(plaintext, keySize)
if err != nil {
return nil, err
}
// 将填充后的数据转换为大整数
m := new(big.Int).SetBytes(padded)
// 执行 RSA 加密: c = m^e mod n
c := new(big.Int).Exp(m, k.e, k.n)
// 将结果转换为字节数组,确保长度与密钥长度一致
ciphertext := c.Bytes()
if len(ciphertext) < keySize {
// 如果结果长度不足,在前面填充零
result := make([]byte, keySize)
copy(result[keySize-len(ciphertext):], ciphertext)
return result, nil
}
return ciphertext, nil
}
// pkcs1v15Pad 实现 PKCS#1 v1.5 填充
func pkcs1v15Pad(data []byte, keySize int) ([]byte, error) {
if len(data) > keySize-11 {
return nil, fmt.Errorf("data too long: %d bytes (max %d)", len(data), keySize-11)
}
// PKCS1v15 格式: 0x00 || 0x02 || PS || 0x00 || data
// 其中 PS 是随机非零字节,长度至少为 8 字节
padLen := keySize - len(data) - 3
padded := make([]byte, keySize)
padded[0] = 0x00
padded[1] = 0x02
// 生成随机非零字节作为填充
ps := make([]byte, padLen)
_, err := rand.Read(ps)
if err != nil {
return nil, fmt.Errorf("failed to generate random padding: %w", err)
}
// 确保所有填充字节都是非零
for i := 0; i < padLen; i++ {
for ps[i] == 0 {
rand.Read(ps[i : i+1])
}
padded[2+i] = ps[i]
}
padded[2+padLen] = 0x00
copy(padded[3+padLen:], data)
return padded, nil
}
// GetModulus 获取模数
func (k *RSAKeyPair) GetModulus() *big.Int {
return k.n
}
// GetExponent 获取公钥指数
func (k *RSAKeyPair) GetExponent() *big.Int {
return k.e
}
@@ -0,0 +1,54 @@
package cryptoutil
import (
"testing"
)
func TestRSAKeyPair(t *testing.T) {
// 使用你提供的参数创建 RSA 密钥对
exponent := "10001"
modulus := "a1155dc73de04614604cbed62cc3b3198a639eb7b7d19cc2dfe3e787fe435f1464beee42a9a3cab6f1d3bfe66cd00317220ee770fd779b24c12c7698e251e875"
keyPair, err := NewRSAKeyPair(exponent, "", modulus)
if err != nil {
t.Fatalf("创建 RSA 密钥对失败: %v", err)
}
// 测试加密
plaintext := "241703070163"
ciphertext, err := keyPair.Encrypt(plaintext)
if err != nil {
t.Fatalf("加密失败: %v", err)
}
t.Logf("明文: %s", plaintext)
t.Logf("密文(十六进制): %s", ciphertext)
// 验证密文不为空
if len(ciphertext) == 0 {
t.Error("密文为空")
}
}
func TestRSAKeyPair_EncryptBytes(t *testing.T) {
exponent := "10001"
modulus := "a1155dc73de04614604cbed62cc3b3198a639eb7b7d19cc2dfe3e787fe435f1464beee42a9a3cab6f1d3bfe66cd00317220ee770fd779b24c12c7698e251e875"
keyPair, err := NewRSAKeyPair(exponent, "", modulus)
if err != nil {
t.Fatalf("创建 RSA 密钥对失败: %v", err)
}
// 测试字节加密
plaintext := []byte("Test data 测试数据")
ciphertext, err := keyPair.EncryptBytes(plaintext)
if err != nil {
t.Fatalf("字节加密失败: %v", err)
}
if len(ciphertext) == 0 {
t.Error("密文字节为空")
}
t.Logf("密文长度: %d bytes", len(ciphertext))
}
@@ -7,6 +7,9 @@ import (
func GetCpdailyLoginBySchoolInfo(ctx context.Context, info *school.SchoolDetailInfo) CpdailyLogin {
switch info.Id {
// 南昌理工学院
case "d6e8f2ca-10b2-4599-bdb8-4b33d419658d":
return NewNut(ctx)
default:
if info.IsIap() {
return NewIap(ctx)
+213
View File
@@ -0,0 +1,213 @@
package login
import (
"bytes"
"context"
"fmt"
"io"
"likedream-goframe/internal/application/cookiejar"
"likedream-goframe/internal/application/cpdaily/cryptoutil"
"likedream-goframe/internal/application/cpdaily/school"
"likedream-goframe/internal/application/httpclient"
"likedream-goframe/internal/common/errors"
"likedream-goframe/internal/utils"
"net/http"
"net/url"
"strings"
"github.com/PuerkitoBio/goquery"
"github.com/bytedance/sonic"
"github.com/samber/oops"
"resty.dev/v3"
)
// 南阳理工学院登录实现 RSA加密 & 验证码必出
type CpdailyNutLogin struct {
client *resty.Client
}
func (c CpdailyNutLogin) Login(ctx context.Context, schoolDetail *school.SchoolDetailInfo, username, password string) {
jar := cookiejar.NewPersistCookieJar()
c.client = 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 = c.client.SetRedirectPolicy(resty.NoRedirectPolicy())
// 解析登录页面
loginPage, err := goquery.NewDocumentFromReader(bytes.NewReader(loginPageBytes))
utils.OkOrPanic(err)
// 判断登录类型
var (
allInputs = map[string]string{}
fm1Node = loginPage.Find("#fm1").First()
)
inputs := fm1Node.Find("input")
for _, input := range inputs.EachIter() {
name, exists := input.Attr("name")
if !exists {
continue
}
value, _ := input.Attr("value")
allInputs[name] = value
}
allInputs["username"] = username
// 处理密码
type NutPubKeyResponse struct {
Modulus string `json:"modulus"`
Exponent string `json:"exponent"`
}
var pubKey NutPubKeyResponse
pubKeyResponse, err := c.client.R().Get(getNutPubKey(loginResponseUrl))
utils.OkOrPanic(err)
defer pubKeyResponse.Body.Close()
pubKeyBytes, err := io.ReadAll(pubKeyResponse.Body)
utils.OkOrPanic(err)
sonic.Unmarshal(pubKeyBytes, &pubKey)
pair, err := cryptoutil.NewRSAKeyPair(pubKey.Exponent, "", pubKey.Modulus)
utils.OkOrPanic(err)
password, err = pair.Encrypt(reverseStringASCII(password))
utils.OkOrPanic(err)
allInputs["password"] = password
if isNutNeedCaptcha(ctx, loginResponseUrl, c.client) {
// 解决常规图片验证码
captchaUrl := getNutCaptchaUrl(loginResponseUrl)
captchaResult := ResolveOcrCaptcha(ctx, captchaUrl, c.client)
allInputs["authcode"] = captchaResult
} else {
allInputs["authcode"] = ""
}
// 登录提交
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 getNutCaptchaUrl(url *url.URL) string {
return fmt.Sprintf("%s://%s/cas/kaptcha", url.Scheme, url.Host)
}
func isNutNeedCaptcha(ctx context.Context, url *url.URL, client *resty.Client) bool {
requestURL := getNutNeedCaptchaUrl(url)
captchaResponse, err := client.R().
WithContext(ctx).
Get(requestURL)
utils.OkOrPanic(oops.With().
With("requestURL", requestURL).
WithContext(ctx).
Wrap(err))
defer captchaResponse.Body.Close()
if captchaResponse.IsError() {
utils.OkOrPanic(oops.With().
With("requestURL", requestURL).
WithContext(ctx).
Wrap(errors.NewBusinessError(503, "请求验证码失败")))
}
responseBytes, err := io.ReadAll(captchaResponse.Body)
utils.OkOrPanic(err)
return strings.ToLower(strings.TrimSpace(string(responseBytes))) != "false"
}
func getNutPubKey(url *url.URL) string {
return fmt.Sprintf("%s://%s/cas/v2/getPubKey", url.Scheme, url.Host)
}
func getNutNeedCaptchaUrl(url *url.URL) string {
return fmt.Sprintf("%s://%s/cas/v2/getKaptchaStatus", url.Scheme, url.Host)
}
func (c CpdailyNutLogin) GetCookieJar() http.CookieJar {
return c.client.CookieJar()
}
func (c CpdailyNutLogin) GetCookieString() string {
cookieJar := c.GetCookieJar()
serializableJar := cookieJar.(*cookiejar.PersistCookieJar)
return serializableJar.Serialize()
}
func (c CpdailyNutLogin) GetClient() *resty.Client {
return c.client
}
func NewNut(ctx context.Context) *CpdailyNutLogin {
return &CpdailyNutLogin{
client: httpclient.Cpdaily.Clone(ctx),
}
}
func reverseStringASCII(s string) string {
bytes := []byte(s)
for i, j := 0, len(bytes)-1; i < j; i, j = i+1, j-1 {
bytes[i], bytes[j] = bytes[j], bytes[i]
}
return string(bytes)
}
+25 -23
View File
@@ -1,27 +1,31 @@
package login
import (
"bytes"
"context"
"fmt"
"image"
"io"
"likedream-goframe/internal/application/recognizer/ddddocr"
"likedream-goframe/internal/framework/logger"
"likedream-goframe/internal/utils"
"os"
"github.com/samber/oops"
"go.uber.org/zap"
"resty.dev/v3"
)
func ResolveOcrCaptcha(ctx context.Context, url string, client *resty.Client) string {
codePath := DownCode2File(ctx, url, client)
img, _ := utils.ReadImg(codePath) //读取验证码图片
byteData := DownCode2Byte(ctx, url, client)
img, err := BytesToImage(byteData)
utils.OkOrPanic(err, func(err error) error {
return oops.
With("BytesToImage", url).
WithContext(ctx).
Wrap(err)
})
data := ddddocr.AutoOCRVerification(img)
return data
}
func DownCode2File(ctx context.Context, url string, client *resty.Client) string {
func DownCode2Byte(ctx context.Context, url string, client *resty.Client) []byte {
res, err := client.R().
WithContext(ctx).
Get(url)
@@ -32,23 +36,21 @@ func DownCode2File(ctx context.Context, url string, client *resty.Client) string
Wrap(err)
})
defer res.Body.Close()
all, err := io.ReadAll(res.Body)
utils.OkOrPanic(err, func(err error) error {
return oops.
With("captchaUrl", url).
WithContext(ctx).
Wrap(err)
})
return all
}
codeFileName := "code" + utils.GenerateRandomString(16) //生成验证码文件名称
codeFileName += ".png"
utils.PathExistForCreate("./assets/code/") //检测是否存在路径,如果不存在则创建
filepath := fmt.Sprintf("./assets/code/%s", codeFileName)
file, err := os.Create(filepath)
// 通用解码函数,自动检测格式
func BytesToImage(imgBytes []byte) (image.Image, error) {
img, _, err := image.Decode(bytes.NewReader(imgBytes))
if err != nil {
logger.Logger.Error("生成验证码失败", zap.Error(err))
return ""
return nil, err
}
_, err = io.Copy(file, res.Body)
if err != nil {
logger.Logger.Error("生成验证码失败", zap.Error(err))
return ""
}
file.Close()
return filepath
return img, nil
}
@@ -11,7 +11,7 @@ import (
"github.com/gogf/gf/v2/container/gvar"
)
const submitSignSalt = "HashSalt" // 请根据实际情况替换为真实盐值
const submitSignSalt = "hru89rwr87Nthifa" // 请根据实际情况替换为真实盐值
func GetSubmitSign(form map[string]*gvar.Var) string {
// 按照指定顺序构建哈希数据
+3 -1
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"image"
"image/png"
"likedream-goframe/internal/framework/logger"
"os"
)
@@ -13,10 +14,11 @@ func ReadImg(imgFile string) (image.Image, error) {
if err != nil {
return nil, err
}
img, err := png.Decode(f)
img, s, err := image.Decode(f)
if err != nil {
return nil, err
}
logger.Logger.Info(s)
f.Close()
return img, nil
}