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:
silverwindandwxiaoguang authored and GitHub committed 2026-10-02 22:21:41 +08:00
1 parent e6ffbea888
commit 873efed5a6
17 files changed
+656 -59

No files matched your search

-5
View File
@@ -19,11 +19,6 @@
"path": "gitea.com/go-chi/cache/LICENSE",
"licenseText": "Apache License\nVersion 2.0, January 2004\nhttp://www.apache.org/licenses/\n\nTERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n1. Definitions.\n\n\"License\" shall mean the terms and conditions for use, reproduction, and\ndistribution as defined by Sections 1 through 9 of this document.\n\n\"Licensor\" shall mean the copyright owner or entity authorized by the copyright\nowner that is granting the License.\n\n\"Legal Entity\" shall mean the union of the acting entity and all other entities\nthat control, are controlled by, or are under common control with that entity.\nFor the purposes of this definition, \"control\" means (i) the power, direct or\nindirect, to cause the direction or management of such entity, whether by\ncontract or otherwise, or (ii) ownership of fifty percent (50%) or more of the\noutstanding shares, or (iii) beneficial ownership of such entity.\n\n\"You\" (or \"Your\") shall mean an individual or Legal Entity exercising\npermissions granted by this License.\n\n\"Source\" form shall mean the preferred form for making modifications, including\nbut not limited to software source code, documentation source, and configuration\nfiles.\n\n\"Object\" form shall mean any form resulting from mechanical transformation or\ntranslation of a Source form, including but not limited to compiled object code,\ngenerated documentation, and conversions to other media types.\n\n\"Work\" shall mean the work of authorship, whether in Source or Object form, made\navailable under the License, as indicated by a copyright notice that is included\nin or attached to the work (an example is provided in the Appendix below).\n\n\"Derivative Works\" shall mean any work, whether in Source or Object form, that\nis based on (or derived from) the Work and for which the editorial revisions,\nannotations, elaborations, or other modifications represent, as a whole, an\noriginal work of authorship. For the purposes of this License, Derivative Works\nshall not include works that remain separable from, or merely link (or bind by\nname) to the interfaces of, the Work and Derivative Works thereof.\n\n\"Contribution\" shall mean any work of authorship, including the original version\nof the Work and any modifications or additions to that Work or Derivative Works\nthereof, that is intentionally submitted to Licensor for inclusion in the Work\nby the copyright owner or by an individual or Legal Entity authorized to submit\non behalf of the copyright owner. For the purposes of this definition,\n\"submitted\" means any form of electronic, verbal, or written communication sent\nto the Licensor or its representatives, including but not limited to\ncommunication on electronic mailing lists, source code control systems, and\nissue tracking systems that are managed by, or on behalf of, the Licensor for\nthe purpose of discussing and improving the Work, but excluding communication\nthat is conspicuously marked or otherwise designated in writing by the copyright\nowner as \"Not a Contribution.\"\n\n\"Contributor\" shall mean Licensor and any individual or Legal Entity on behalf\nof whom a Contribution has been received by Licensor and subsequently\nincorporated within the Work.\n\n2. Grant of Copyright License.\n\nSubject to the terms and conditions of this License, each Contributor hereby\ngrants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,\nirrevocable copyright license to reproduce, prepare Derivative Works of,\npublicly display, publicly perform, sublicense, and distribute the Work and such\nDerivative Works in Source or Object form.\n\n3. Grant of Patent License.\n\nSubject to the terms and conditions of this License, each Contributor hereby\ngrants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,\nirrevocable (except as stated in this section) patent license to make, have\nmade, use, offer to sell, sell, import, and otherwise transfer the Work, where\nsuch license applies only to those patent claims licensable by such Contributor\nthat are necessarily infringed by their Contribution(s) alone or by combination\nof their Contribution(s) with the Work to which such Contribution(s) was\nsubmitted. If You institute patent litigation against any entity (including a\ncross-claim or counterclaim in a lawsuit) alleging that the Work or a\nContribution incorporated within the Work constitutes direct or contributory\npatent infringement, then any patent licenses granted to You under this License\nfor that Work shall terminate as of the date such litigation is filed.\n\n4. Redistribution.\n\nYou may reproduce and distribute copies of the Work or Derivative Works thereof\nin any medium, with or without modifications, and in Source or Object form,\nprovided that You meet the following conditions:\n\nYou must give any other recipients of the Work or Derivative Works a copy of\nthis License; and\nYou must cause any modified files to carry prominent notices stating that YLine truncated
},
{
"name": "gitea.com/go-chi/captcha",
"path": "gitea.com/go-chi/captcha/LICENSE",
"licenseText": "Apache License\nVersion 2.0, January 2004\nhttp://www.apache.org/licenses/\n\nTERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n1. Definitions.\n\n\"License\" shall mean the terms and conditions for use, reproduction, and\ndistribution as defined by Sections 1 through 9 of this document.\n\n\"Licensor\" shall mean the copyright owner or entity authorized by the copyright\nowner that is granting the License.\n\n\"Legal Entity\" shall mean the union of the acting entity and all other entities\nthat control, are controlled by, or are under common control with that entity.\nFor the purposes of this definition, \"control\" means (i) the power, direct or\nindirect, to cause the direction or management of such entity, whether by\ncontract or otherwise, or (ii) ownership of fifty percent (50%) or more of the\noutstanding shares, or (iii) beneficial ownership of such entity.\n\n\"You\" (or \"Your\") shall mean an individual or Legal Entity exercising\npermissions granted by this License.\n\n\"Source\" form shall mean the preferred form for making modifications, including\nbut not limited to software source code, documentation source, and configuration\nfiles.\n\n\"Object\" form shall mean any form resulting from mechanical transformation or\ntranslation of a Source form, including but not limited to compiled object code,\ngenerated documentation, and conversions to other media types.\n\n\"Work\" shall mean the work of authorship, whether in Source or Object form, made\navailable under the License, as indicated by a copyright notice that is included\nin or attached to the work (an example is provided in the Appendix below).\n\n\"Derivative Works\" shall mean any work, whether in Source or Object form, that\nis based on (or derived from) the Work and for which the editorial revisions,\nannotations, elaborations, or other modifications represent, as a whole, an\noriginal work of authorship. For the purposes of this License, Derivative Works\nshall not include works that remain separable from, or merely link (or bind by\nname) to the interfaces of, the Work and Derivative Works thereof.\n\n\"Contribution\" shall mean any work of authorship, including the original version\nof the Work and any modifications or additions to that Work or Derivative Works\nthereof, that is intentionally submitted to Licensor for inclusion in the Work\nby the copyright owner or by an individual or Legal Entity authorized to submit\non behalf of the copyright owner. For the purposes of this definition,\n\"submitted\" means any form of electronic, verbal, or written communication sent\nto the Licensor or its representatives, including but not limited to\ncommunication on electronic mailing lists, source code control systems, and\nissue tracking systems that are managed by, or on behalf of, the Licensor for\nthe purpose of discussing and improving the Work, but excluding communication\nthat is conspicuously marked or otherwise designated in writing by the copyright\nowner as \"Not a Contribution.\"\n\n\"Contributor\" shall mean Licensor and any individual or Legal Entity on behalf\nof whom a Contribution has been received by Licensor and subsequently\nincorporated within the Work.\n\n2. Grant of Copyright License.\n\nSubject to the terms and conditions of this License, each Contributor hereby\ngrants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,\nirrevocable copyright license to reproduce, prepare Derivative Works of,\npublicly display, publicly perform, sublicense, and distribute the Work and such\nDerivative Works in Source or Object form.\n\n3. Grant of Patent License.\n\nSubject to the terms and conditions of this License, each Contributor hereby\ngrants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,\nirrevocable (except as stated in this section) patent license to make, have\nmade, use, offer to sell, sell, import, and otherwise transfer the Work, where\nsuch license applies only to those patent claims licensable by such Contributor\nthat are necessarily infringed by their Contribution(s) alone or by combination\nof their Contribution(s) with the Work to which such Contribution(s) was\nsubmitted. If You institute patent litigation against any entity (including a\ncross-claim or counterclaim in a lawsuit) alleging that the Work or a\nContribution incorporated within the Work constitutes direct or contributory\npatent infringement, then any patent licenses granted to You under this License\nfor that Work shall terminate as of the date such litigation is filed.\n\n4. Redistribution.\n\nYou may reproduce and distribute copies of the Work or Derivative Works thereof\nin any medium, with or without modifications, and in Source or Object form,\nprovided that You meet the following conditions:\n\nYou must give any other recipients of the Work or Derivative Works a copy of\nthis License; and\nYou must cause any modified files to carry prominent notices stating that YLine truncated
},
{
"name": "gitea.com/go-chi/session",
"path": "gitea.com/go-chi/session/LICENSE",
-1
View File
@@ -7,7 +7,6 @@ toolchain go1.27.1
require (
connectrpc.com/connect v1.21.0
gitea.com/go-chi/cache v0.2.1
gitea.com/go-chi/captcha v0.0.0-20240315150714-fb487f629098
gitea.com/go-chi/session v0.0.0-20260708011333-ebced8a7a2d6
gitea.com/lunny/dingtalk_webhook v0.0.0-20171025031554-e3534c89ef96
gitea.com/lunny/levelqueue v0.4.2-0.20230414023320-3c0159fe0fe4
-2
View File
@@ -10,8 +10,6 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
gitea.com/go-chi/cache v0.2.1 h1:bfAPkvXlbcZxPCpcmDVCWoHgiBSBmZN/QosnZvEC0+g=
gitea.com/go-chi/cache v0.2.1/go.mod h1:Qic0HZ8hOHW62ETGbonpwz8WYypj9NieU9659wFUJ8Q=
gitea.com/go-chi/captcha v0.0.0-20240315150714-fb487f629098 h1:p2ki+WK0cIeNQuqjR98IP2KZQKRzJJiV7aTeMAFwaWo=
gitea.com/go-chi/captcha v0.0.0-20240315150714-fb487f629098/go.mod h1:LjzIOHlRemuUyO7WR12fmm18VZIlCAaOt9L3yKw40pk=
gitea.com/go-chi/session v0.0.0-20260708011333-ebced8a7a2d6 h1:YWzVGeC/8SZThrJS48ZmQYLkzssdeABxHPhbdnxPDIU=
gitea.com/go-chi/session v0.0.0-20260708011333-ebced8a7a2d6/go.mod h1:KDvcfMUoXfATPHs2mbMoXFTXT45/FAFAS39waz9tPk0=
gitea.com/lunny/dingtalk_webhook v0.0.0-20171025031554-e3534c89ef96 h1:+wWBi6Qfruqu7xJgjOIrKVQGiLUZdpKYCZewJ4clqhw=
+172
View File
@@ -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
}
+122
View File
@@ -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))
}
}
+66
View File
@@ -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)
}
+203
View File
@@ -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
View File
@@ -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)
+4 -5
View File
@@ -19,6 +19,7 @@ import (
user_model "gitea.dev/models/user"
"gitea.dev/modules/auth/password"
"gitea.dev/modules/httplib"
"gitea.dev/modules/imagecaptcha"
"gitea.dev/modules/log"
"gitea.dev/modules/optional"
"gitea.dev/modules/session"
@@ -70,7 +71,7 @@ func prepareCommonAuthPageData(ctx *context.Context, opt CommonAuthOptions) {
ctx.Data["McaptchaURL"] = strings.TrimSuffix(setting.Service.McaptchaURL, "/")
ctx.Data["CfTurnstileSitekey"] = setting.Service.CfTurnstileSitekey
if setting.Service.CaptchaType == setting.ImageCaptcha {
ctx.Data["Captcha"] = context.GetImageCaptcha()
ctx.Data["CreateImageCaptcha"] = imagecaptcha.CreateNew
}
}
}
@@ -298,8 +299,7 @@ func SignInPost(ctx *context.Context) {
form := web.GetForm[*forms.SignInForm](ctx)
if setting.Service.EnableCaptcha && setting.Service.RequireCaptchaForLogin {
context.VerifyCaptcha(ctx, tplSignIn, form)
if ctx.Written() {
if !context.VerifyCaptcha(ctx, tplSignIn, form) {
return
}
}
@@ -553,8 +553,7 @@ func SignUpPost(ctx *context.Context) {
return
}
context.VerifyCaptcha(ctx, tplSignUp, form)
if ctx.Written() {
if !context.VerifyCaptcha(ctx, tplSignUp, form) {
return
}
+17
View File
@@ -17,8 +17,10 @@ import (
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"gitea.dev/modules/util"
"gitea.dev/modules/web"
"gitea.dev/services/auth/source/oauth2"
"gitea.dev/services/contexttest"
"gitea.dev/services/forms"
"github.com/markbates/goth"
"github.com/markbates/goth/gothic"
@@ -199,3 +201,18 @@ func TestOpenIDRequireTwoFactor(t *testing.T) {
openIDRequireTwoFactor(ctx, user2, false, "https://example.com/id")
assert.False(t, ctx.Written())
}
func TestRegisterOpenIDPostRejectsWrongCaptcha(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
defer test.MockVariableValue(&setting.Service.EnableCaptcha, true)()
defer test.MockVariableValue(&setting.Service.CaptchaType, setting.ImageCaptcha)()
sess := session.NewMockMemStore("dummy-sid-openid-register")
require.NoError(t, sess.Set("openid_verified_uri", "https://example.com/openid"))
ctx, _ := contexttest.MockContext(t, "POST /user/openid/register", contexttest.MockContextOption{SessionStore: sess})
contexttest.MockRequestPostForm(ctx.Req, url.Values{"captcha_id": {"unknown"}, "captcha": {"000000"}})
web.SetForm(ctx, &forms.SignUpOpenIDForm{UserName: "openid-captcha-user", Email: "openid-captcha-user@example.com"})
RegisterOpenIDPost(ctx)
assert.Equal(t, true, ctx.Data["Err_Captcha"])
unittest.AssertNotExistsBean(t, &user_model.User{LowerName: "openid-captcha-user"})
}
+2 -2
View File
@@ -38,6 +38,7 @@ func prepareLinkAccountPageData(ctx *context.Context) {
ctx.Data["ShowRegistrationButton"] = false
ctx.Data["DisableRegistration"] = setting.Service.DisableRegistration
// FIXME: this logic is not right: captcha is enabled, but LinkAccountPostSignIn never checks for captcha
prepareCommonAuthPageData(ctx, CommonAuthOptions{
EnableCaptcha: setting.Service.EnableCaptcha && setting.Service.RequireExternalRegistrationCaptcha,
})
@@ -198,8 +199,7 @@ func LinkAccountPostRegister(ctx *context.Context) {
}
if setting.Service.EnableCaptcha && setting.Service.RequireExternalRegistrationCaptcha {
context.VerifyCaptcha(ctx, tplLinkAccount, form)
if ctx.Written() {
if !context.VerifyCaptcha(ctx, tplLinkAccount, form) {
return
}
}
+3 -1
View File
@@ -378,7 +378,9 @@ func RegisterOpenIDPost(ctx *context.Context) {
ctx.ServerError("", err)
return
}
context.VerifyCaptcha(ctx, tplSignUpOID, form)
if !context.VerifyCaptcha(ctx, tplSignUpOID, form) {
return
}
}
length := max(setting.MinPasswordLength, 256)
+3 -4
View File
@@ -13,6 +13,7 @@ import (
"gitea.dev/models/unit"
"gitea.dev/modules/git"
"gitea.dev/modules/graceful"
"gitea.dev/modules/imagecaptcha"
"gitea.dev/modules/log"
"gitea.dev/modules/metrics"
"gitea.dev/modules/public"
@@ -50,7 +51,6 @@ import (
_ "gitea.dev/modules/session" // to register all internal adapters
"gitea.com/go-chi/captcha"
chi_middleware "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/klauspost/compress/gzhttp"
@@ -293,9 +293,8 @@ func Routes() *web.Router {
mid = append(mid, wrapper)
}
if setting.Service.EnableCaptcha {
// The captcha http.Handler should only fire on /captcha/* so we can just mount this on that url
routes.Methods("GET,HEAD", "/captcha/*", append(mid, captcha.Captchaer(context.GetImageCaptcha()))...)
if setting.Service.EnableCaptcha && setting.Service.CaptchaType == setting.ImageCaptcha {
routes.Methods("GET,HEAD", `/captcha`, append(mid, imagecaptcha.ServeImage)...)
}
if setting.Metrics.Enabled {
+10 -36
View File
@@ -5,65 +5,37 @@ package context
import (
"fmt"
"image/color"
"sync"
"gitea.dev/modules/cache"
"gitea.dev/modules/hcaptcha"
"gitea.dev/modules/imagecaptcha"
"gitea.dev/modules/log"
"gitea.dev/modules/mcaptcha"
"gitea.dev/modules/recaptcha"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/modules/turnstile"
"gitea.com/go-chi/captcha"
)
var (
imageCaptchaOnce sync.Once
cpt *captcha.Captcha
)
// GetImageCaptcha returns global image captcha
func GetImageCaptcha() *captcha.Captcha {
imageCaptchaOnce.Do(func() {
cpt = captcha.NewCaptcha(captcha.Options{
SubURL: setting.AppSubURL,
// Use a color palette with high contrast colors suitable for both light and dark modes
// These colors provide good visibility and readability in both themes
ColorPalette: color.Palette{
color.RGBA{R: 234, G: 67, B: 53, A: 255}, // Bright red
color.RGBA{R: 66, G: 133, B: 244, A: 255}, // Medium blue
color.RGBA{R: 52, G: 168, B: 83, A: 255}, // Green
color.RGBA{R: 251, G: 188, B: 5, A: 255}, // Yellow/gold
color.RGBA{R: 171, G: 71, B: 188, A: 255}, // Purple
},
})
cpt.Store = cache.GetCache().ChiCache()
})
return cpt
}
const (
imageCaptchaIDField = "captcha_id"
imageCaptchaAnswerField = "captcha"
gRecaptchaResponseField = "g-recaptcha-response"
hCaptchaResponseField = "h-captcha-response"
mCaptchaResponseField = "mcaptcha__token" // this form key is hard-coded in the mcaptcha frontend library
cfTurnstileResponseField = "cf-turnstile-response"
)
// VerifyCaptcha verifies Captcha data
// No-op if captchas are not enabled
func VerifyCaptcha(ctx *Context, tpl templates.TplName, form any) {
// VerifyCaptcha returns whether the captcha is solved or disabled, otherwise it renders tpl with an error
func VerifyCaptcha(ctx *Context, tpl templates.TplName, form any) bool {
if !setting.Service.EnableCaptcha {
return
return true
}
var valid bool
var err error
switch setting.Service.CaptchaType {
case setting.ImageCaptcha:
valid = GetImageCaptcha().VerifyReq(ctx.Req)
valid = imagecaptcha.Verify(ctx.FormString(imageCaptchaIDField), ctx.FormString(imageCaptchaAnswerField))
case setting.ReCaptcha:
valid, err = recaptcha.Verify(ctx, ctx.Req.Form.Get(gRecaptchaResponseField))
case setting.HCaptcha:
@@ -74,7 +46,7 @@ func VerifyCaptcha(ctx *Context, tpl templates.TplName, form any) {
valid, err = turnstile.Verify(ctx, ctx.Req.Form.Get(cfTurnstileResponseField))
default:
ctx.ServerError("Unknown Captcha Type", fmt.Errorf("unknown Captcha Type: %s", setting.Service.CaptchaType))
return
return false
}
if err != nil {
log.Debug("Captcha Verify failed: %v", err)
@@ -83,5 +55,7 @@ func VerifyCaptcha(ctx *Context, tpl templates.TplName, form any) {
if !valid {
ctx.Data["Err_Captcha"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("form.captcha_incorrect"), tpl, form)
return false
}
return true
}
+4 -2
View File
@@ -1,10 +1,12 @@
{{if .EnableCaptcha}}{{if eq .CaptchaType "image"}}
{{$captchaID := call .CreateImageCaptcha}}
<div class="inline field tw-text-center">
{{.Captcha.CreateHTML}}
<input type="hidden" name="captcha_id" value="{{$captchaID}}">
<img class="tw-cursor-pointer" src="{{AppSubUrl}}/captcha?id={{$captchaID}}" width="240" height="80" alt="{{ctx.Locale.Tr "captcha"}}" data-global-click="onImageCaptchaReload">
</div>
<div class="required field {{if .Err_Captcha}}error{{end}}">
<label for="captcha">{{ctx.Locale.Tr "captcha"}}</label>
<input id="captcha" name="captcha" value="{{.captcha}}" autocomplete="off">
<input id="captcha" name="captcha" autocomplete="off">
</div>
{{else if eq .CaptchaType "recaptcha"}}
<div class="inline field tw-text-center required">
+31
View File
@@ -12,9 +12,11 @@ import (
"gitea.dev/models/db"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/cache"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"gitea.dev/modules/translation"
"gitea.dev/routers"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
@@ -35,6 +37,35 @@ func TestSignup(t *testing.T) {
// should be able to view new user's page
req = NewRequest(t, "GET", "/exampleUser")
MakeRequest(t, req, http.StatusOK)
t.Run("ImageCaptcha", func(t *testing.T) {
defer test.MockVariableValue(&setting.Service.EnableCaptcha, true)()
defer test.MockVariableValue(&setting.Service.CaptchaType, setting.ImageCaptcha)()
defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
resp := MakeRequest(t, NewRequest(t, "GET", "/user/sign_up"), http.StatusOK)
captchaID := NewHTMLParser(t, resp.Body).GetInputValueByName("captcha_id")
resp = MakeRequest(t, NewRequest(t, "GET", "/captcha?id="+captchaID), http.StatusOK)
assert.Equal(t, "image/png", resp.Header().Get("Content-Type"))
assert.Equal(t, "no-store", resp.Header().Get("Cache-Control"))
values := map[string]string{
"user_name": "captchaUser",
"email": "captchaUser@example.com",
"password": "examplePassword!1",
"retype": "examplePassword!1",
"captcha_id": captchaID,
"captcha": "wrong",
}
resp = MakeRequest(t, NewRequestWithValues(t, "POST", "/user/sign_up", values), http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
assert.Equal(t, translation.NewLocale("en-US").TrString("form.captcha_incorrect"), strings.TrimSpace(htmlDoc.Find(".ui.message").Text()))
values["captcha_id"] = htmlDoc.GetInputValueByName("captcha_id")
captchaCode, _ := cache.GetCache().Get("captcha_" + values["captcha_id"])
values["captcha"] = captchaCode
MakeRequest(t, NewRequestWithValues(t, "POST", "/user/sign_up", values), http.StatusSeeOther)
})
}
func TestSignupAsRestricted(t *testing.T) {
+7
View File
@@ -1,6 +1,13 @@
import {isDarkTheme} from '../utils.ts';
import {registerGlobalEventFunc} from '../modules/observer.ts';
export async function initCaptcha() {
registerGlobalEventFunc('click', 'onImageCaptchaReload', (img: HTMLImageElement) => {
const url = new URL(img.src);
url.searchParams.set('reload', String(Date.now()));
img.src = url.href;
});
const captchaEl = document.querySelector('#captcha');
if (!captchaEl) return;