refactor: replace freetype captcha with opentype renderer (#13753)

This commit is contained in:
ssongliu
2026-09-09 13:53:56 +08:00
committed by GitHub
parent e3f0381a26
commit 61dacce5e0
5 changed files with 184 additions and 39 deletions

View File

@@ -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

View File

@@ -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=

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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)
}