Compare commits

...

2 Commits

Author SHA1 Message Date
sugarBecomer 66995c0c78 🐛 cas滑块验证码 2025-11-12 13:35:17 +08:00
sugarBecomer 120e690795 🐛 cas滑块验证码 2025-11-12 13:31:54 +08:00
9 changed files with 363 additions and 68 deletions
+11
View File
@@ -1,6 +1,16 @@
FROM golang:latest AS backend-builder
RUN apt update
RUN apt install -y build-essential
# 安装 OpenCV 开发库和依赖
RUN apt install -y \
libopencv-dev \
libopencv-core-dev \
libopencv-imgproc-dev \
libopencv-imgcodecs-dev \
libopencv-videoio-dev \
pkg-config
WORKDIR /build
COPY . .
ENV CGO_ENABLED=1
@@ -11,6 +21,7 @@ RUN apt install -y upx-ucl
RUN upx --best --lzma bin/server
FROM golang:latest
COPY --from=backend-builder /build/bin/server /app/server
#COPY static /app/static
#COPY static/*.so /app/
+1
View File
@@ -33,6 +33,7 @@ require (
github.com/yalue/onnxruntime_go v1.12.1
go.uber.org/automaxprocs v1.6.0
go.uber.org/zap v1.27.0
gocv.io/x/gocv v0.42.0
golang.org/x/crypto v0.43.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
+2
View File
@@ -244,6 +244,8 @@ go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
gocv.io/x/gocv v0.42.0 h1:AAsrFJH2aIsQHukkCovWqj0MCGZleQpVyf5gNVRXjQI=
gocv.io/x/gocv v0.42.0/go.mod h1:zYdWMj29WAEznM3Y8NsU3A0TRq/wR/cy75jeUypThqU=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+35 -9
View File
@@ -16,10 +16,12 @@ import (
"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"
@@ -188,20 +190,35 @@ func (c *CpdailyCasLogin) Login(ctx context.Context, schoolDetail *school.School
} else {
allInputs["password"] = encryptCasPassword(password, salt)
}
var SliderCaptchaIds = []string{"1018764144934965"}
// 判断是否需要验证码
if isCasNeedCaptcha(ctx, loginResponseUrl, loginType, username, c.client) {
// 解决验证码
captchaUrl := getCasCaptchaUrl(loginResponseUrl, loginType)
// 如果是滑块验证码
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)
captchaResult := ResolveOcrCaptcha(ctx, captchaUrl, c.client)
switch loginType {
case 0:
allInputs["captchaResponse"] = captchaResult
default:
allInputs["captcha"] = captchaResult
switch loginType {
case 0:
allInputs["captchaResponse"] = captchaResult
default:
allInputs["captcha"] = captchaResult
}
}
}
loginPostResponse, err := c.client.R().
@@ -529,3 +546,12 @@ func getCasCaptchaUrl(url *url.URL, loginType int) string {
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)
}
+40 -58
View File
@@ -2,71 +2,53 @@ package login
import (
"context"
"fmt"
"io"
"likedream-goframe/internal/application/httpclient"
"likedream-goframe/internal/common/cst"
"likedream-goframe/internal/application/recognizer/ddddocr"
"likedream-goframe/internal/framework/logger"
"likedream-goframe/internal/utils"
"os"
"github.com/gogf/gf/v2/frame/g"
"github.com/samber/oops"
"github.com/tidwall/gjson"
"go.uber.org/zap"
"resty.dev/v3"
)
func ResolveOcrCaptcha(ctx context.Context, url string, client *resty.Client) string {
captchaResponse, err := client.R().
WithContext(ctx).
Get(url)
utils.OkOrPanic(err, func(err error) error {
return oops.
With("captchaUrl", url).
WithContext(ctx).
Wrap(err)
})
defer func() {
_ = captchaResponse.Body.Close()
}()
ocrUrl := g.Cfg().MustGetWithEnv(ctx, cst.ConfigOcrServerUrl).String()
ocrResponse, err := httpclient.Common.R().
SetMultipartFields(&resty.MultipartField{
Name: "file",
FileName: "captcha.jpg",
ContentType: captchaResponse.Header().Get("Content-Type"),
Reader: captchaResponse.Body,
}).
WithContext(ctx).
Post(ocrUrl + "/ocr")
utils.OkOrPanic(err, func(err error) error {
return oops.
With("captchaUrl", url).
WithContext(ctx).
Wrap(err)
})
defer func() {
_ = ocrResponse.Body.Close()
}()
responseBytes, err := io.ReadAll(ocrResponse.Body)
utils.OkOrPanic(err)
responseJSON := gjson.ParseBytes(responseBytes)
data := responseJSON.Get("data").String()
if data == "" {
utils.OkOrPanic(oops.
In("ResolveOcrCaptcha").
With("captchaUrl", url).
With("message", responseJSON.Get("message").String()).
WithContext(ctx).
New("OCR服务返回数据为空"))
}
codePath := DownCode2File(ctx, url, client)
img, _ := utils.ReadImg(codePath) //读取验证码图片
data := ddddocr.AutoOCRVerification(img)
return data
}
func DownCode2File(ctx context.Context, url string, client *resty.Client) string {
res, err := client.R().
WithContext(ctx).
Get(url)
utils.OkOrPanic(err, func(err error) error {
return oops.
With("captchaUrl", url).
WithContext(ctx).
Wrap(err)
})
defer res.Body.Close()
codeFileName := "code" + utils.GenerateRandomString(16) //生成验证码文件名称
codeFileName += ".png"
utils.PathExistForCreate("./assets/code/") //检测是否存在路径,如果不存在则创建
filepath := fmt.Sprintf("./assets/code/%s", codeFileName)
file, err := os.Create(filepath)
if err != nil {
logger.Logger.Error("生成验证码失败", zap.Error(err))
return ""
}
_, err = io.Copy(file, res.Body)
if err != nil {
logger.Logger.Error("生成验证码失败", zap.Error(err))
return ""
}
file.Close()
return filepath
}
@@ -0,0 +1,67 @@
package login
import (
"context"
"fmt"
"io"
"likedream-goframe/internal/application/recognizer/slider"
"likedream-goframe/internal/common/errors"
"likedream-goframe/internal/utils"
"github.com/bytedance/sonic"
"github.com/samber/oops"
"resty.dev/v3"
)
type CasSliderCaptchaReponse struct {
BigImage string `json:"bigImage"`
SmallImage string `json:"smallImage"`
TagWidth int `json:"tagWidth"`
YHeight int `json:"yHeight"`
}
type CasSliderResult struct {
ErrorCode int `json:"errorCode"`
ErrorMsg string `json:"errorMsg"`
}
func ResolveCasSliderCaptcha(ctx context.Context, captchaUrl string, verifyUrl string, client *resty.Client) {
res, err := client.R().
WithContext(ctx).
Get(captchaUrl)
utils.OkOrPanic(err, func(err error) error {
return oops.
With("captchaUrl", captchaUrl).
WithContext(ctx).
Wrap(err)
})
defer res.Body.Close()
bytes, _ := io.ReadAll(res.Body)
var captcha CasSliderCaptchaReponse
sonic.Unmarshal(bytes, &captcha)
width := slider.VerifySliderWidthByBase64(captcha.BigImage, captcha.SmallImage)
res2, err2 := client.R().
WithContext(ctx).
SetFormData(map[string]string{
"canvasLength": "280",
"moveLength": fmt.Sprintf("%d", width),
}).
Post(verifyUrl)
utils.OkOrPanic(err2, func(err error) error {
return oops.
With("verifyUrl", verifyUrl).
WithContext(ctx).
Wrap(err)
})
defer res2.Body.Close()
bytes, _ = io.ReadAll(res2.Body)
str := string(bytes)
if len(str) > 0 {
var result CasSliderResult
sonic.Unmarshal(bytes, &result)
if result.ErrorCode != 1 {
utils.OkOrPanic(oops.WithContext(ctx).Wrap(errors.NewBusinessError(500, "滑块解析错误")))
}
}
}
+3 -1
View File
@@ -548,7 +548,9 @@ func (c *CpdailyAutoSignTask) FillCustomForm(ctx context.Context, form map[strin
ticket = submitCaptchaResolver.GetCaptchaTicket(ctx, captchaInfo)
})
}, retry.MaxDelay(15*time.Second), retry.Attempts(5), retry.Context(ctx))
utils.OkOrPanic(errors.NewBusinessError(503, "提交验证码识别失败").WithInnerError(e), oops.WithContext(ctx).Wrap)
if e != nil {
utils.OkOrPanic(errors.NewBusinessError(503, "提交验证码识别失败").WithInnerError(e), oops.WithContext(ctx).Wrap)
}
resultForm["ticket"] = gvar.New(ticket)
} else {
File diff suppressed because one or more lines are too long
@@ -0,0 +1,192 @@
package slider
import (
"encoding/base64"
"os"
"gocv.io/x/gocv"
)
func VerifySliderWidth(bgImgPath, sliderImgPath string) int {
// 检查背景图片文件是否存在
if _, err := os.Stat(bgImgPath); os.IsNotExist(err) {
return -1
}
// 检查滑块图片文件是否存在
if _, err := os.Stat(sliderImgPath); os.IsNotExist(err) {
return -1
}
// 读取背景图片
bgImg := gocv.IMRead(bgImgPath, gocv.IMReadColor)
if bgImg.Empty() {
return -1
}
defer bgImg.Close()
// 读取滑块图片(包含 alpha 通道)
sliderImgWithAlpha := gocv.IMRead(sliderImgPath, gocv.IMReadUnchanged)
if sliderImgWithAlpha.Empty() {
return -1
}
defer sliderImgWithAlpha.Close()
// 分离滑块图片的通道
channels := gocv.Split(sliderImgWithAlpha)
defer func() {
for i := range channels {
channels[i].Close()
}
}()
var sliderImg gocv.Mat
var mask gocv.Mat
// 如果有 alpha 通道(4通道),提取 mask
if len(channels) == 4 {
// 前3个通道是 BGR
sliderImg = gocv.NewMat()
defer sliderImg.Close()
gocv.Merge(channels[:3], &sliderImg)
// 使用 alpha 通道作为 mask
mask = channels[3].Clone()
defer mask.Close()
} else if len(channels) == 3 {
// 没有透明通道,直接使用原图
sliderImg = sliderImgWithAlpha.Clone()
defer sliderImg.Close()
mask = gocv.NewMat() // 空 mask
defer mask.Close()
} else {
return -1
}
// 创建结果矩阵用于存储匹配结果
result := gocv.NewMat()
defer result.Close()
// 直接在原图上使用模板匹配找到滑块位置
if mask.Empty() {
gocv.MatchTemplate(bgImg, sliderImg, &result, gocv.TmCcoeffNormed, gocv.NewMat())
} else {
// 带 mask 的模板匹配
gocv.MatchTemplate(bgImg, sliderImg, &result, gocv.TmCcoeffNormed, mask)
}
// 找到最佳匹配位置
_, maxVal, _, maxLoc := gocv.MinMaxLoc(result)
// 如果匹配度太低,返回 -1 表示失败
if maxVal < 0.6 {
return -1
}
// 获取原图上的 x 坐标,然后按比例缩放到目标尺寸
originalWidth := bgImg.Cols()
targetWidth := 280
scale := float64(targetWidth) / float64(originalWidth)
scaledX := int(float64(maxLoc.X) * scale)
// 返回缩放后的 x 坐标(滑块需要移动的距离)
return scaledX
}
// VerifySliderWidthByBase64 通过 base64 编码的图片计算滑块验证码需要移动的距离
// bgImgBase64: 背景图片的 base64 编码
// sliderImgBase64: 滑块图片的 base64 编码
// 返回: 滑块需要移动的 x 距离(已缩放到 280 宽度),失败返回 -1
func VerifySliderWidthByBase64(bgImgBase64, sliderImgBase64 string) int {
// 解码背景图片的 base64
bgImgData, err := base64.StdEncoding.DecodeString(bgImgBase64)
if err != nil {
return -1
}
// 解码滑块图片的 base64
sliderImgData, err := base64.StdEncoding.DecodeString(sliderImgBase64)
if err != nil {
return -1
}
// 从内存中读取背景图片
bgImg, err := gocv.IMDecode(bgImgData, gocv.IMReadColor)
if err != nil {
return -1
}
if bgImg.Empty() {
return -1
}
defer bgImg.Close()
// 从内存中读取滑块图片(包含 alpha 通道)
sliderImgWithAlpha, err := gocv.IMDecode(sliderImgData, gocv.IMReadUnchanged)
if err != nil {
return -1
}
if sliderImgWithAlpha.Empty() {
return -1
}
defer sliderImgWithAlpha.Close()
// 分离滑块图片的通道
channels := gocv.Split(sliderImgWithAlpha)
defer func() {
for i := range channels {
channels[i].Close()
}
}()
var sliderImg gocv.Mat
var mask gocv.Mat
// 如果有 alpha 通道(4通道),提取 mask
if len(channels) == 4 {
// 前3个通道是 BGR
sliderImg = gocv.NewMat()
defer sliderImg.Close()
gocv.Merge(channels[:3], &sliderImg)
// 使用 alpha 通道作为 mask
mask = channels[3].Clone()
defer mask.Close()
} else if len(channels) == 3 {
// 没有透明通道,直接使用原图
sliderImg = sliderImgWithAlpha.Clone()
defer sliderImg.Close()
mask = gocv.NewMat() // 空 mask
defer mask.Close()
} else {
return -1
}
// 创建结果矩阵用于存储匹配结果
result := gocv.NewMat()
defer result.Close()
// 直接在原图上使用模板匹配找到滑块位置
if mask.Empty() {
gocv.MatchTemplate(bgImg, sliderImg, &result, gocv.TmCcoeffNormed, gocv.NewMat())
} else {
// 带 mask 的模板匹配
gocv.MatchTemplate(bgImg, sliderImg, &result, gocv.TmCcoeffNormed, mask)
}
// 找到最佳匹配位置
_, maxVal, _, maxLoc := gocv.MinMaxLoc(result)
// 如果匹配度太低,返回 -1 表示失败
if maxVal < 0.6 {
return -1
}
// 获取原图上的 x 坐标,然后按比例缩放到目标尺寸
originalWidth := bgImg.Cols()
targetWidth := 280
scale := float64(targetWidth) / float64(originalWidth)
scaledX := int(float64(maxLoc.X) * scale)
// 返回缩放后的 x 坐标(滑块需要移动的距离)
return scaledX
}