mirror of
https://github.com/go-gitea/gitea.git
synced 2026-10-03 17:48:45 +00:00
refactor: move go-chi/captcha into Gitea (#39529)
The `gitea.com/go-chi/captcha` package only exists for Gitea, so it moves into `modules/imagecaptcha`. - Reloading an expired challenge shows a new image instead of a broken one - Every answer is consumed on its first check - OpenID registration stops after a failed captcha --------- Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
// Copyright 2011-2014 Dmitry Chestnykh. All rights reserved.
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package imagecaptcha
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
imageWidth = 240
|
||||
imageHeight = 80
|
||||
fontWidth = 11
|
||||
fontHeight = 18
|
||||
maxSkew = 0.7
|
||||
circleCount = 20
|
||||
)
|
||||
|
||||
type captchaImage struct {
|
||||
*image.Paletted
|
||||
rng *rand.Rand
|
||||
numWidth int
|
||||
numHeight int
|
||||
dotSize int
|
||||
}
|
||||
|
||||
func drawImage(rng *rand.Rand, code string) *image.Paletted {
|
||||
img := &captchaImage{rng: rng}
|
||||
img.initPalette()
|
||||
img.calculateSizes(len(code))
|
||||
border := imageHeight / 5
|
||||
maxX := imageWidth - (img.numWidth+img.dotSize)*len(code) - img.dotSize
|
||||
maxY := imageHeight - img.numHeight - img.dotSize*2
|
||||
x := img.randInt(border, maxX-border)
|
||||
y := img.randInt(border, maxY-border)
|
||||
for i := range code {
|
||||
img.drawDigit(code[i], x, y)
|
||||
x += img.numWidth + img.dotSize
|
||||
}
|
||||
img.strikeThrough()
|
||||
img.distort(img.randFloat(5, 10), img.randFloat(100, 200))
|
||||
img.fillWithCircles(circleCount, img.dotSize)
|
||||
return img.Paletted
|
||||
}
|
||||
|
||||
func (img *captchaImage) initPalette() {
|
||||
primary := primaryColors[img.rng.IntN(len(primaryColors))]
|
||||
palette := color.Palette{color.Transparent, primary}
|
||||
for range circleCount - 1 {
|
||||
palette = append(palette, img.randomBrightness(primary))
|
||||
}
|
||||
img.Paletted = image.NewPaletted(image.Rect(0, 0, imageWidth, imageHeight), palette)
|
||||
}
|
||||
|
||||
func (img *captchaImage) randomBrightness(c color.RGBA) color.RGBA {
|
||||
minChannel, maxChannel := min(c.R, c.G, c.B), max(c.R, c.G, c.B)
|
||||
shift := img.rng.IntN(math.MaxUint8-int(maxChannel)+1) - int(minChannel)
|
||||
return color.RGBA{R: uint8(int(c.R) + shift), G: uint8(int(c.G) + shift), B: uint8(int(c.B) + shift), A: c.A}
|
||||
}
|
||||
|
||||
func (img *captchaImage) randInt(from, to int) int {
|
||||
return img.rng.IntN(to+1-from) + from
|
||||
}
|
||||
|
||||
func (img *captchaImage) randFloat(from, to float64) float64 {
|
||||
return (to-from)*img.rng.Float64() + from
|
||||
}
|
||||
|
||||
func (img *captchaImage) calculateSizes(digitCount int) {
|
||||
border := imageHeight / 4
|
||||
width := float64(imageWidth - border*2)
|
||||
height := float64(imageHeight - border*2)
|
||||
glyphWidth := float64(fontWidth + 1)
|
||||
glyphHeight := float64(fontHeight)
|
||||
digitWidth := width / float64(digitCount)
|
||||
digitHeight := digitWidth * glyphHeight / glyphWidth
|
||||
if digitHeight > height {
|
||||
digitHeight = height
|
||||
digitWidth = glyphWidth / glyphHeight * digitHeight
|
||||
}
|
||||
img.dotSize = max(int(digitHeight/glyphHeight), 1)
|
||||
img.numWidth = int(digitWidth) - img.dotSize
|
||||
img.numHeight = int(digitHeight)
|
||||
}
|
||||
|
||||
func (img *captchaImage) drawHorizLine(fromX, toX, y int, colorIndex uint8) {
|
||||
for x := fromX; x <= toX; x++ {
|
||||
img.SetColorIndex(x, y, colorIndex)
|
||||
}
|
||||
}
|
||||
|
||||
func (img *captchaImage) drawCircle(x, y, radius int, colorIndex uint8) {
|
||||
decision := 1 - radius
|
||||
offsetY := radius
|
||||
for offsetX := 0; offsetX <= offsetY; offsetX++ {
|
||||
img.drawHorizLine(x-offsetX, x+offsetX, y+offsetY, colorIndex)
|
||||
img.drawHorizLine(x-offsetX, x+offsetX, y-offsetY, colorIndex)
|
||||
img.drawHorizLine(x-offsetY, x+offsetY, y+offsetX, colorIndex)
|
||||
img.drawHorizLine(x-offsetY, x+offsetY, y-offsetX, colorIndex)
|
||||
if decision >= 0 {
|
||||
offsetY--
|
||||
decision -= 2 * offsetY
|
||||
}
|
||||
decision += 2*(offsetX+1) + 1
|
||||
}
|
||||
}
|
||||
|
||||
func (img *captchaImage) fillWithCircles(count, maxRadius int) {
|
||||
maxX, maxY := img.Bounds().Max.X, img.Bounds().Max.Y
|
||||
for range count {
|
||||
colorIndex := uint8(img.randInt(1, circleCount-1))
|
||||
radius := img.randInt(1, maxRadius)
|
||||
img.drawCircle(img.randInt(radius, maxX-radius), img.randInt(radius, maxY-radius), radius, colorIndex)
|
||||
}
|
||||
}
|
||||
|
||||
func (img *captchaImage) strikeThrough() {
|
||||
maxX, maxY := img.Bounds().Max.X, img.Bounds().Max.Y
|
||||
y := img.randInt(maxY/3, maxY-maxY/3)
|
||||
amplitude := img.randFloat(5, 20)
|
||||
dx := 2.0 * math.Pi / img.randFloat(80, 180)
|
||||
offsetX := amplitude * math.Cos(float64(y)*dx)
|
||||
for x := range maxX {
|
||||
offsetY := amplitude * math.Sin(float64(x)*dx)
|
||||
for row := range img.dotSize {
|
||||
radius := img.randInt(0, img.dotSize)
|
||||
img.drawCircle(x+int(offsetX), y+int(offsetY)+(row*img.dotSize), radius/2, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (img *captchaImage) drawDigit(c byte, x, y int) {
|
||||
if c < '0' || c > '9' {
|
||||
return
|
||||
}
|
||||
digit := c - '0'
|
||||
skew := img.randFloat(-maxSkew, maxSkew)
|
||||
skewedX := float64(x)
|
||||
radius := img.dotSize / 2
|
||||
y += img.randInt(-radius, radius)
|
||||
fontRows := fontData()
|
||||
for row := range fontHeight {
|
||||
for col := range fontWidth {
|
||||
if fontRows[int(digit)*fontHeight+row][col] == '#' {
|
||||
img.drawCircle(x+col*img.dotSize, y+row*img.dotSize, radius, 1)
|
||||
}
|
||||
}
|
||||
skewedX += skew
|
||||
x = int(skewedX)
|
||||
}
|
||||
}
|
||||
|
||||
func (img *captchaImage) distort(amplitude, period float64) {
|
||||
width, height := img.Bounds().Max.X, img.Bounds().Max.Y
|
||||
distorted := image.NewPaletted(image.Rect(0, 0, width, height), img.Palette)
|
||||
dx := 2.0 * math.Pi / period
|
||||
offsetsX := make([]int, height)
|
||||
for y := range height {
|
||||
offsetsX[y] = int(amplitude * math.Sin(float64(y)*dx))
|
||||
}
|
||||
for x := range width {
|
||||
offsetY := int(amplitude * math.Cos(float64(x)*dx))
|
||||
for y := range height {
|
||||
distorted.SetColorIndex(x, y, img.ColorIndexAt(x+offsetsX[y], y+offsetY))
|
||||
}
|
||||
}
|
||||
img.Paletted = distorted
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package imagecaptcha
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"math/rand/v2"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"gitea.dev/modules/cache"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
const (
|
||||
cacheKeyPrefix = "captcha_"
|
||||
ttlSeconds = 600
|
||||
codeLength = 6
|
||||
)
|
||||
|
||||
var primaryColors = []color.RGBA{ // readable on both light and dark backgrounds
|
||||
{R: 234, G: 67, B: 53, A: 255},
|
||||
{R: 66, G: 133, B: 244, A: 255},
|
||||
{R: 52, G: 168, B: 83, A: 255},
|
||||
{R: 251, G: 188, B: 5, A: 255},
|
||||
{R: 171, G: 71, B: 188, A: 255},
|
||||
}
|
||||
|
||||
type pngBufferPool struct{ sync.Pool }
|
||||
|
||||
func (p *pngBufferPool) Get() *png.EncoderBuffer {
|
||||
buf, _ := p.Pool.Get().(*png.EncoderBuffer)
|
||||
return buf
|
||||
}
|
||||
|
||||
func (p *pngBufferPool) Put(buf *png.EncoderBuffer) {
|
||||
p.Pool.Put(buf)
|
||||
}
|
||||
|
||||
func randomCode() string {
|
||||
s := "000000" + strconv.Itoa(util.FastCryptoRandomInt(1000000))
|
||||
return s[len(s)-6:]
|
||||
}
|
||||
|
||||
var globalVars = sync.OnceValue(func() (ret struct {
|
||||
IdLength int
|
||||
IdRegexp *regexp.Regexp
|
||||
NoiseKey []byte
|
||||
},
|
||||
) {
|
||||
ret.IdLength = 40
|
||||
ret.IdRegexp = regexp.MustCompile(`^[0-9a-f]{40}$`)
|
||||
ret.NoiseKey = util.FastCryptoRandomBytes(32)
|
||||
return
|
||||
})
|
||||
|
||||
// noiseRand makes refetches of an image identical, so averaging them does not remove the noise
|
||||
func noiseRand(id, code string) *rand.Rand {
|
||||
mac := hmac.New(sha256.New, globalVars().NoiseKey)
|
||||
_, _ = mac.Write([]byte(id + "\x00" + code))
|
||||
return util.FastCryptoRand([32]byte(mac.Sum(nil)))
|
||||
}
|
||||
|
||||
func CreateNew() (string, error) {
|
||||
id := util.FastCryptoRandomHex(globalVars().IdLength)
|
||||
_, err := PrepareCode(id, true)
|
||||
return id, err
|
||||
}
|
||||
|
||||
func PrepareCode(id string, generateNew bool) (code string, err error) {
|
||||
if !globalVars().IdRegexp.MatchString(id) {
|
||||
return "", nil
|
||||
}
|
||||
cacheKey := cacheKeyPrefix + id
|
||||
if generateNew {
|
||||
code = randomCode()
|
||||
if err = cache.GetCache().Put(cacheKey, code, ttlSeconds); err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else {
|
||||
code, _ = cache.GetCache().Get(cacheKey)
|
||||
}
|
||||
return code, nil
|
||||
}
|
||||
|
||||
func Verify(id, answer string) bool {
|
||||
if !globalVars().IdRegexp.MatchString(id) {
|
||||
return false
|
||||
}
|
||||
key := cacheKeyPrefix + id
|
||||
code, ok := cache.GetCache().Get(key)
|
||||
_ = cache.GetCache().Delete(key)
|
||||
return ok && answer == code
|
||||
}
|
||||
|
||||
func ServeImage(resp http.ResponseWriter, req *http.Request) {
|
||||
urlQuery := req.URL.Query()
|
||||
id, reload := urlQuery.Get("id"), urlQuery.Get("reload") != ""
|
||||
code, err := PrepareCode(id, reload)
|
||||
if err != nil {
|
||||
log.Error("Failed to prepare captcha code for id %s: %v", id, err)
|
||||
http.Error(resp, "Failed to prepare captcha code", http.StatusInternalServerError)
|
||||
return
|
||||
} else if code == "" {
|
||||
http.NotFound(resp, req)
|
||||
return
|
||||
}
|
||||
|
||||
resp.Header().Set("Cache-Control", "no-store")
|
||||
resp.Header().Set("Content-Type", "image/png")
|
||||
if req.Method == http.MethodGet {
|
||||
pngEncoder := png.Encoder{BufferPool: &pngBufferPool{}}
|
||||
_ = pngEncoder.Encode(resp, drawImage(noiseRand(id, code), code))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package imagecaptcha
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/png"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/cache"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestImageCaptcha(t *testing.T) {
|
||||
require.NoError(t, cache.Init())
|
||||
createWithAnswer := func() (string, string) {
|
||||
id, err := CreateNew()
|
||||
require.NoError(t, err)
|
||||
code, ok := cache.GetCache().Get(cacheKeyPrefix + id)
|
||||
require.True(t, ok)
|
||||
return id, code
|
||||
}
|
||||
renderImage := func(id string, refresh bool) *httptest.ResponseRecorder {
|
||||
resp := httptest.NewRecorder()
|
||||
reqLink := "/captcha?id=" + id
|
||||
if refresh {
|
||||
reqLink += "&reload=any"
|
||||
}
|
||||
ServeImage(resp, httptest.NewRequest(http.MethodGet, reqLink, nil))
|
||||
return resp
|
||||
}
|
||||
id, answer := createWithAnswer()
|
||||
assert.Len(t, answer, codeLength)
|
||||
assert.True(t, Verify(id, answer))
|
||||
assert.False(t, Verify(id, answer))
|
||||
|
||||
id, answer = createWithAnswer()
|
||||
assert.False(t, Verify(id, "wrong"))
|
||||
assert.False(t, Verify(id, answer))
|
||||
assert.False(t, Verify("", ""))
|
||||
assert.False(t, Verify("unknown", answer))
|
||||
|
||||
resp := renderImage("unknown", true)
|
||||
assert.Equal(t, http.StatusNotFound, resp.Code)
|
||||
_, exists := cache.GetCache().Get(cacheKeyPrefix + "unknown")
|
||||
assert.False(t, exists)
|
||||
|
||||
id, _ = createWithAnswer()
|
||||
first := renderImage(id, false)
|
||||
decoded, err := png.Decode(bytes.NewReader(first.Body.Bytes()))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, image.Rect(0, 0, imageWidth, imageHeight), decoded.Bounds())
|
||||
second := renderImage(id, false)
|
||||
assert.Equal(t, first.Body.Bytes(), second.Body.Bytes())
|
||||
|
||||
require.NoError(t, cache.GetCache().Delete(cacheKeyPrefix+id))
|
||||
_ = renderImage(id, true)
|
||||
_, exists = cache.GetCache().Get(cacheKeyPrefix + id)
|
||||
assert.True(t, exists)
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package imagecaptcha
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var fontData = sync.OnceValue(func() []string {
|
||||
return strings.Fields(`
|
||||
...#####...
|
||||
..#######..
|
||||
.###...###.
|
||||
.##.....##.
|
||||
###.....##.
|
||||
##.......##
|
||||
##.......##
|
||||
##.......##
|
||||
##.......##
|
||||
##.......##
|
||||
##.......##
|
||||
##.......##
|
||||
##.......##
|
||||
##......###
|
||||
.##.....##.
|
||||
.###...###.
|
||||
..#######..
|
||||
...#####...
|
||||
|
||||
.....##....
|
||||
....###....
|
||||
...####....
|
||||
..#####....
|
||||
..##.##....
|
||||
..#..##....
|
||||
.....##....
|
||||
.....##....
|
||||
.....##....
|
||||
.....##....
|
||||
.....##....
|
||||
.....##....
|
||||
.....##....
|
||||
.....##....
|
||||
.....##....
|
||||
.....##....
|
||||
.##########
|
||||
.##########
|
||||
|
||||
...####....
|
||||
.########..
|
||||
###....###.
|
||||
.#......##.
|
||||
........##.
|
||||
........##.
|
||||
........##.
|
||||
.......##..
|
||||
.......##..
|
||||
......##...
|
||||
.....##....
|
||||
....###....
|
||||
...###.....
|
||||
..###......
|
||||
.###.......
|
||||
.##........
|
||||
###########
|
||||
###########
|
||||
|
||||
..######...
|
||||
#########..
|
||||
##.....###.
|
||||
........##.
|
||||
........##.
|
||||
........##.
|
||||
......###..
|
||||
..#####....
|
||||
..#######..
|
||||
.......###.
|
||||
........###
|
||||
.........##
|
||||
.........##
|
||||
.........##
|
||||
........###
|
||||
#......###.
|
||||
#########..
|
||||
.#######...
|
||||
|
||||
.......##..
|
||||
......###..
|
||||
.....####..
|
||||
.....#.##..
|
||||
....##.##..
|
||||
...##..##..
|
||||
...##..##..
|
||||
..##...##..
|
||||
.##....##..
|
||||
.##....##..
|
||||
##.....##..
|
||||
#......##..
|
||||
###########
|
||||
###########
|
||||
.......##..
|
||||
.......##..
|
||||
.......##..
|
||||
.......##..
|
||||
|
||||
.#########.
|
||||
.#########.
|
||||
.##........
|
||||
.##........
|
||||
.##........
|
||||
.##........
|
||||
.#######...
|
||||
.########..
|
||||
.......###.
|
||||
........###
|
||||
.........##
|
||||
.........##
|
||||
.........##
|
||||
.........##
|
||||
........###
|
||||
##.....###.
|
||||
#########..
|
||||
..######...
|
||||
|
||||
.....#####.
|
||||
...#######.
|
||||
..###......
|
||||
.##........
|
||||
.##........
|
||||
.#.........
|
||||
##..####...
|
||||
##.#######.
|
||||
####....##.
|
||||
###.....###
|
||||
##.......##
|
||||
##.......##
|
||||
##.......##
|
||||
##.......##
|
||||
.##.....###
|
||||
.###...###.
|
||||
..#######..
|
||||
...#####...
|
||||
|
||||
###########
|
||||
###########
|
||||
###......##
|
||||
##......##.
|
||||
........##.
|
||||
.......###.
|
||||
.......##..
|
||||
.......##..
|
||||
......##...
|
||||
......##...
|
||||
.....###...
|
||||
.....##....
|
||||
....###....
|
||||
....##.....
|
||||
....##.....
|
||||
...###.....
|
||||
...##......
|
||||
..###......
|
||||
|
||||
...#####...
|
||||
..########.
|
||||
.###....###
|
||||
.##......##
|
||||
.##......##
|
||||
.##......##
|
||||
..##....##.
|
||||
..#######..
|
||||
....####...
|
||||
..###.###..
|
||||
.###...###.
|
||||
###.....###
|
||||
##.......##
|
||||
##.......##
|
||||
##.......##
|
||||
###.....##.
|
||||
.#########.
|
||||
...#####...
|
||||
|
||||
...#####...
|
||||
.########..
|
||||
.##....###.
|
||||
##......##.
|
||||
##.......##
|
||||
##.......##
|
||||
##.......##
|
||||
##......###
|
||||
.##....####
|
||||
.#######.##
|
||||
...####..##
|
||||
.........##
|
||||
........##.
|
||||
........##.
|
||||
.......###.
|
||||
......###..
|
||||
.#######...
|
||||
.#####.....
|
||||
`)
|
||||
})
|
||||
+12
-1
@@ -111,7 +111,7 @@ func FastCryptoRandomBytes(length int) []byte {
|
||||
// ChaCha8 is about 20x times faster than system's crypto/rand.
|
||||
// It is suitable for UUIDs, session IDs, etc
|
||||
pool := chaCha8RandPool()
|
||||
chaCha8Rand := pool.Get().(*rand2.ChaCha8) //nolint:forcetypeassert // the pool's New only ever makes *rand2.ChaCha8
|
||||
chaCha8Rand, _ := pool.Get().(*rand2.ChaCha8)
|
||||
defer pool.Put(chaCha8Rand)
|
||||
buf := make([]byte, length)
|
||||
_, _ = chaCha8Rand.Read(buf)
|
||||
@@ -123,6 +123,17 @@ func FastCryptoRandomHex(length int) string {
|
||||
return hex.EncodeToString(buf)
|
||||
}
|
||||
|
||||
func FastCryptoRandomInt[T int | int64](n T) T {
|
||||
pool := chaCha8RandPool()
|
||||
chaCha8Rand, _ := pool.Get().(*rand2.ChaCha8)
|
||||
defer pool.Put(chaCha8Rand)
|
||||
return rand2.New(chaCha8Rand).N(n)
|
||||
}
|
||||
|
||||
func FastCryptoRand(seed [32]byte) *rand2.Rand {
|
||||
return rand2.New(rand2.NewChaCha8(seed))
|
||||
}
|
||||
|
||||
// ToLowerASCII returns s with all ASCII letters mapped to their lower case.
|
||||
func ToLowerASCII(s string) string {
|
||||
b := []byte(s)
|
||||
|
||||
Reference in New Issue
Block a user