From 61dacce5e0ceb4cf5fcb8618b2d4e10f8192c2a3 Mon Sep 17 00:00:00 2001 From: ssongliu Date: Wed, 9 Sep 2026 13:53:56 +0800 Subject: [PATCH] refactor: replace freetype captcha with opentype renderer (#13753) --- core/go.mod | 4 +- core/go.sum | 4 -- core/utils/captcha/captcha.go | 77 ++++++++++++++++++++--------------- core/utils/captcha/image.go | 62 ++++++++++++++++++++++++++++ core/utils/captcha/store.go | 76 ++++++++++++++++++++++++++++++++++ 5 files changed, 184 insertions(+), 39 deletions(-) create mode 100644 core/utils/captcha/image.go create mode 100644 core/utils/captcha/store.go diff --git a/core/go.mod b/core/go.mod index 52a860e82..5f163ca80 100644 --- a/core/go.mod +++ b/core/go.mod @@ -3,7 +3,6 @@ module github.com/1Panel-dev/1Panel/core go 1.26.1 require ( - github.com/1panel-dev/base64Captcha v1.3.8 github.com/beevik/etree v1.7.0 github.com/coreos/go-oidc/v3 v3.20.0 github.com/creack/pty v1.1.24 @@ -40,6 +39,7 @@ require ( github.com/swaggo/swag v1.16.6 github.com/xlzd/gotp v0.1.0 golang.org/x/crypto v0.55.0 + golang.org/x/image v0.41.0 golang.org/x/net v0.58.0 golang.org/x/oauth2 v0.36.0 golang.org/x/sys v0.47.0 @@ -86,7 +86,6 @@ require ( github.com/goccy/go-yaml v1.19.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect - github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/google/go-tpm v0.9.8 // indirect github.com/hhrutter/lzw v1.0.0 // indirect github.com/hhrutter/pkcs7 v0.2.0 // indirect @@ -129,7 +128,6 @@ require ( go.mongodb.org/mongo-driver/v2 v2.5.1 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/arch v0.26.0 // indirect - golang.org/x/image v0.41.0 // indirect golang.org/x/mod v0.38.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/tools v0.48.0 // indirect diff --git a/core/go.sum b/core/go.sum index 7bd13cd45..dd11a2e1d 100644 --- a/core/go.sum +++ b/core/go.sum @@ -1,5 +1,3 @@ -github.com/1panel-dev/base64Captcha v1.3.8 h1:GbQ2IuGMp4ai4erpwf4BMjm5eLC8Efb+dATVwgpPIII= -github.com/1panel-dev/base64Captcha v1.3.8/go.mod h1:gVpwyGm9+g4rg3pXdYnsFAouP73qMOSBfnT3bxCFzco= github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw= github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= @@ -118,8 +116,6 @@ github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXe github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= -github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= -github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= diff --git a/core/utils/captcha/captcha.go b/core/utils/captcha/captcha.go index bbec91ea8..d2938793e 100644 --- a/core/utils/captcha/captcha.go +++ b/core/utils/captcha/captcha.go @@ -1,55 +1,68 @@ package captcha import ( - "image/color" + "crypto/rand" + "fmt" + "math/big" "strings" + "time" "github.com/1Panel-dev/1Panel/core/app/dto" - "github.com/1panel-dev/base64Captcha" ) -var store = base64Captcha.DefaultMemStore - const ( - captchaWidth = 160 - captchaHeight = 48 - captchaNoise = 0 + captchaWidth = 160 + captchaHeight = 48 + captchaExpiration = 10 * time.Minute + captchaStoreLimit = 10000 ) -var captchaFonts = []string{ - base64Captcha.FontRitaSmith, -} +var store = newMemoryStore(captchaStoreLimit, captchaExpiration) func VerifyCode(codeID string, code string) string { - vv := store.Get(codeID, true) - vv = strings.TrimSpace(vv) + answer := store.consume(codeID) code = strings.TrimSpace(code) - if codeID == "" || code == "" { + if codeID == "" || code == "" || answer == "" || answer != code { return "ErrCaptchaCode" } - if strings.EqualFold(vv, code) { - return "" - } - return "ErrCaptchaCode" + return "" } func CreateCaptcha() (*dto.CaptchaResponse, error) { - driver := base64Captcha.NewDriverMath( - captchaHeight, - captchaWidth, - captchaNoise, - 0, - &color.RGBA{R: 246, G: 248, B: 251, A: 255}, - nil, - captchaFonts, - ) - c := base64Captcha.NewCaptcha(driver, store) - id, b64s, _, err := c.Generate() + question, answer, err := generateQuestion() if err != nil { return nil, err } - return &dto.CaptchaResponse{ - CaptchaID: id, - ImagePath: b64s, - }, nil + imagePath, err := renderQuestion(question) + if err != nil { + return nil, err + } + id := rand.Text() + if err := store.put(id, answer); err != nil { + return nil, err + } + return &dto.CaptchaResponse{CaptchaID: id, ImagePath: imagePath}, nil +} + +func generateQuestion() (question, answer string, err error) { + limits := [...]int64{3, 80, 20, 80} + var values [4]int + for i, limit := range limits { + n, err := rand.Int(rand.Reader, big.NewInt(limit)) + if err != nil { + return "", "", fmt.Errorf("generate captcha: %w", err) + } + values[i] = int(n.Int64()) + } + switch values[0] { + case 0: + a, b := values[1]%20, values[2] + return fmt.Sprintf("%d+%d=?", a, b), fmt.Sprint(a + b), nil + case 1: + a, b := values[1]+values[2], values[3] + return fmt.Sprintf("%d-%d=?", a, b), fmt.Sprint(a - b), nil + default: + a, b := values[1]%10, values[2]%10 + return fmt.Sprintf("%dx%d=?", a, b), fmt.Sprint(a * b), nil + } } diff --git a/core/utils/captcha/image.go b/core/utils/captcha/image.go new file mode 100644 index 000000000..10b9cb13a --- /dev/null +++ b/core/utils/captcha/image.go @@ -0,0 +1,62 @@ +package captcha + +import ( + "bytes" + "encoding/base64" + "image" + "image/color" + "image/draw" + "image/png" + "math/rand/v2" + "sync" + + "golang.org/x/image/font" + "golang.org/x/image/font/gofont/gobold" + "golang.org/x/image/font/opentype" + "golang.org/x/image/math/fixed" +) + +var loadFont = sync.OnceValues(func() (*opentype.Font, error) { + return opentype.Parse(gobold.TTF) +}) + +func renderQuestion(question string) (string, error) { + f, err := loadFont() + if err != nil { + return "", err + } + dst := image.NewRGBA(image.Rect(0, 0, captchaWidth, captchaHeight)) + background := image.NewUniform(color.RGBA{R: 246, G: 248, B: 251, A: 255}) + draw.Draw(dst, dst.Bounds(), background, image.Point{}, draw.Src) + chars := []rune(question) + cellWidth := (captchaWidth - 12) / len(chars) + for i, char := range chars { + face, err := opentype.NewFace(f, &opentype.FaceOptions{ + Size: float64(24 + rand.IntN(5)), DPI: 72, Hinting: font.HintingFull, + }) + if err != nil { + return "", err + } + bounds, _ := font.BoundString(face, string(char)) + x := fixed.I(6+i*cellWidth+cellWidth/2) - (bounds.Min.X+bounds.Max.X)/2 + y := fixed.I(captchaHeight/2+rand.IntN(7)-3) - (bounds.Min.Y+bounds.Max.Y)/2 + d := font.Drawer{ + Dst: dst, + Src: image.NewUniform(color.RGBA{ + R: uint8(20 + rand.IntN(90)), G: uint8(20 + rand.IntN(90)), + B: uint8(20 + rand.IntN(90)), A: 255, + }), + Face: face, + Dot: fixed.Point26_6{X: x, Y: y}, + } + d.DrawString(string(char)) + if err := face.Close(); err != nil { + return "", err + } + } + var buf bytes.Buffer + if err := png.Encode(&buf, dst); err != nil { + return "", err + } + return "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()), nil +} diff --git a/core/utils/captcha/store.go b/core/utils/captcha/store.go new file mode 100644 index 000000000..e1e12f917 --- /dev/null +++ b/core/utils/captcha/store.go @@ -0,0 +1,76 @@ +package captcha + +import ( + "container/list" + "errors" + "sync" + "time" +) + +type challenge struct { + id string + answer string + expiresAt time.Time +} + +type memoryStore struct { + mu sync.Mutex + entries map[string]*list.Element + order *list.List + limit int + expiration time.Duration + now func() time.Time +} + +func newMemoryStore(limit int, expiration time.Duration) *memoryStore { + return &memoryStore{ + entries: make(map[string]*list.Element), order: list.New(), + limit: limit, expiration: expiration, now: time.Now, + } +} + +func (s *memoryStore) put(id, answer string) error { + s.mu.Lock() + defer s.mu.Unlock() + now := s.now() + s.removeExpired(now) + if len(s.entries) >= s.limit { + return errors.New("captcha store is full") + } + if _, exists := s.entries[id]; exists { + return errors.New("captcha ID already exists") + } + s.entries[id] = s.order.PushBack(challenge{id: id, answer: answer, expiresAt: now.Add(s.expiration)}) + return nil +} + +func (s *memoryStore) consume(id string) string { + s.mu.Lock() + defer s.mu.Unlock() + now := s.now() + s.removeExpired(now) + e, ok := s.entries[id] + if !ok { + return "" + } + value := e.Value.(challenge) + s.remove(e) + if !now.Before(value.expiresAt) { + return "" + } + return value.answer +} + +func (s *memoryStore) removeExpired(now time.Time) { + for e := s.order.Front(); e != nil; e = s.order.Front() { + if now.Before(e.Value.(challenge).expiresAt) { + return + } + s.remove(e) + } +} + +func (s *memoryStore) remove(e *list.Element) { + delete(s.entries, e.Value.(challenge).id) + s.order.Remove(e) +}