mirror of
https://github.com/go-gitea/gitea.git
synced 2026-10-04 07:33:44 +09: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:
Generated
-5
File diff suppressed because one or more lines are too long
@@ -7,7 +7,6 @@ toolchain go1.27.1
|
|||||||
require (
|
require (
|
||||||
connectrpc.com/connect v1.21.0
|
connectrpc.com/connect v1.21.0
|
||||||
gitea.com/go-chi/cache v0.2.1
|
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/go-chi/session v0.0.0-20260708011333-ebced8a7a2d6
|
||||||
gitea.com/lunny/dingtalk_webhook v0.0.0-20171025031554-e3534c89ef96
|
gitea.com/lunny/dingtalk_webhook v0.0.0-20171025031554-e3534c89ef96
|
||||||
gitea.com/lunny/levelqueue v0.4.2-0.20230414023320-3c0159fe0fe4
|
gitea.com/lunny/levelqueue v0.4.2-0.20230414023320-3c0159fe0fe4
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
|||||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
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 h1:bfAPkvXlbcZxPCpcmDVCWoHgiBSBmZN/QosnZvEC0+g=
|
||||||
gitea.com/go-chi/cache v0.2.1/go.mod h1:Qic0HZ8hOHW62ETGbonpwz8WYypj9NieU9659wFUJ8Q=
|
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 h1:YWzVGeC/8SZThrJS48ZmQYLkzssdeABxHPhbdnxPDIU=
|
||||||
gitea.com/go-chi/session v0.0.0-20260708011333-ebced8a7a2d6/go.mod h1:KDvcfMUoXfATPHs2mbMoXFTXT45/FAFAS39waz9tPk0=
|
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=
|
gitea.com/lunny/dingtalk_webhook v0.0.0-20171025031554-e3534c89ef96 h1:+wWBi6Qfruqu7xJgjOIrKVQGiLUZdpKYCZewJ4clqhw=
|
||||||
|
|||||||
@@ -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.
|
// ChaCha8 is about 20x times faster than system's crypto/rand.
|
||||||
// It is suitable for UUIDs, session IDs, etc
|
// It is suitable for UUIDs, session IDs, etc
|
||||||
pool := chaCha8RandPool()
|
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)
|
defer pool.Put(chaCha8Rand)
|
||||||
buf := make([]byte, length)
|
buf := make([]byte, length)
|
||||||
_, _ = chaCha8Rand.Read(buf)
|
_, _ = chaCha8Rand.Read(buf)
|
||||||
@@ -123,6 +123,17 @@ func FastCryptoRandomHex(length int) string {
|
|||||||
return hex.EncodeToString(buf)
|
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.
|
// ToLowerASCII returns s with all ASCII letters mapped to their lower case.
|
||||||
func ToLowerASCII(s string) string {
|
func ToLowerASCII(s string) string {
|
||||||
b := []byte(s)
|
b := []byte(s)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
"gitea.dev/modules/auth/password"
|
"gitea.dev/modules/auth/password"
|
||||||
"gitea.dev/modules/httplib"
|
"gitea.dev/modules/httplib"
|
||||||
|
"gitea.dev/modules/imagecaptcha"
|
||||||
"gitea.dev/modules/log"
|
"gitea.dev/modules/log"
|
||||||
"gitea.dev/modules/optional"
|
"gitea.dev/modules/optional"
|
||||||
"gitea.dev/modules/session"
|
"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["McaptchaURL"] = strings.TrimSuffix(setting.Service.McaptchaURL, "/")
|
||||||
ctx.Data["CfTurnstileSitekey"] = setting.Service.CfTurnstileSitekey
|
ctx.Data["CfTurnstileSitekey"] = setting.Service.CfTurnstileSitekey
|
||||||
if setting.Service.CaptchaType == setting.ImageCaptcha {
|
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)
|
form := web.GetForm[*forms.SignInForm](ctx)
|
||||||
|
|
||||||
if setting.Service.EnableCaptcha && setting.Service.RequireCaptchaForLogin {
|
if setting.Service.EnableCaptcha && setting.Service.RequireCaptchaForLogin {
|
||||||
context.VerifyCaptcha(ctx, tplSignIn, form)
|
if !context.VerifyCaptcha(ctx, tplSignIn, form) {
|
||||||
if ctx.Written() {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -553,8 +553,7 @@ func SignUpPost(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
context.VerifyCaptcha(ctx, tplSignUp, form)
|
if !context.VerifyCaptcha(ctx, tplSignUp, form) {
|
||||||
if ctx.Written() {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ import (
|
|||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
"gitea.dev/modules/test"
|
"gitea.dev/modules/test"
|
||||||
"gitea.dev/modules/util"
|
"gitea.dev/modules/util"
|
||||||
|
"gitea.dev/modules/web"
|
||||||
"gitea.dev/services/auth/source/oauth2"
|
"gitea.dev/services/auth/source/oauth2"
|
||||||
"gitea.dev/services/contexttest"
|
"gitea.dev/services/contexttest"
|
||||||
|
"gitea.dev/services/forms"
|
||||||
|
|
||||||
"github.com/markbates/goth"
|
"github.com/markbates/goth"
|
||||||
"github.com/markbates/goth/gothic"
|
"github.com/markbates/goth/gothic"
|
||||||
@@ -199,3 +201,18 @@ func TestOpenIDRequireTwoFactor(t *testing.T) {
|
|||||||
openIDRequireTwoFactor(ctx, user2, false, "https://example.com/id")
|
openIDRequireTwoFactor(ctx, user2, false, "https://example.com/id")
|
||||||
assert.False(t, ctx.Written())
|
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"})
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ func prepareLinkAccountPageData(ctx *context.Context) {
|
|||||||
ctx.Data["ShowRegistrationButton"] = false
|
ctx.Data["ShowRegistrationButton"] = false
|
||||||
ctx.Data["DisableRegistration"] = setting.Service.DisableRegistration
|
ctx.Data["DisableRegistration"] = setting.Service.DisableRegistration
|
||||||
|
|
||||||
|
// FIXME: this logic is not right: captcha is enabled, but LinkAccountPostSignIn never checks for captcha
|
||||||
prepareCommonAuthPageData(ctx, CommonAuthOptions{
|
prepareCommonAuthPageData(ctx, CommonAuthOptions{
|
||||||
EnableCaptcha: setting.Service.EnableCaptcha && setting.Service.RequireExternalRegistrationCaptcha,
|
EnableCaptcha: setting.Service.EnableCaptcha && setting.Service.RequireExternalRegistrationCaptcha,
|
||||||
})
|
})
|
||||||
@@ -198,8 +199,7 @@ func LinkAccountPostRegister(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if setting.Service.EnableCaptcha && setting.Service.RequireExternalRegistrationCaptcha {
|
if setting.Service.EnableCaptcha && setting.Service.RequireExternalRegistrationCaptcha {
|
||||||
context.VerifyCaptcha(ctx, tplLinkAccount, form)
|
if !context.VerifyCaptcha(ctx, tplLinkAccount, form) {
|
||||||
if ctx.Written() {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -378,7 +378,9 @@ func RegisterOpenIDPost(ctx *context.Context) {
|
|||||||
ctx.ServerError("", err)
|
ctx.ServerError("", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
context.VerifyCaptcha(ctx, tplSignUpOID, form)
|
if !context.VerifyCaptcha(ctx, tplSignUpOID, form) {
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
length := max(setting.MinPasswordLength, 256)
|
length := max(setting.MinPasswordLength, 256)
|
||||||
|
|||||||
+3
-4
@@ -13,6 +13,7 @@ import (
|
|||||||
"gitea.dev/models/unit"
|
"gitea.dev/models/unit"
|
||||||
"gitea.dev/modules/git"
|
"gitea.dev/modules/git"
|
||||||
"gitea.dev/modules/graceful"
|
"gitea.dev/modules/graceful"
|
||||||
|
"gitea.dev/modules/imagecaptcha"
|
||||||
"gitea.dev/modules/log"
|
"gitea.dev/modules/log"
|
||||||
"gitea.dev/modules/metrics"
|
"gitea.dev/modules/metrics"
|
||||||
"gitea.dev/modules/public"
|
"gitea.dev/modules/public"
|
||||||
@@ -50,7 +51,6 @@ import (
|
|||||||
|
|
||||||
_ "gitea.dev/modules/session" // to register all internal adapters
|
_ "gitea.dev/modules/session" // to register all internal adapters
|
||||||
|
|
||||||
"gitea.com/go-chi/captcha"
|
|
||||||
chi_middleware "github.com/go-chi/chi/v5/middleware"
|
chi_middleware "github.com/go-chi/chi/v5/middleware"
|
||||||
"github.com/go-chi/cors"
|
"github.com/go-chi/cors"
|
||||||
"github.com/klauspost/compress/gzhttp"
|
"github.com/klauspost/compress/gzhttp"
|
||||||
@@ -293,9 +293,8 @@ func Routes() *web.Router {
|
|||||||
mid = append(mid, wrapper)
|
mid = append(mid, wrapper)
|
||||||
}
|
}
|
||||||
|
|
||||||
if setting.Service.EnableCaptcha {
|
if setting.Service.EnableCaptcha && setting.Service.CaptchaType == setting.ImageCaptcha {
|
||||||
// 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, imagecaptcha.ServeImage)...)
|
||||||
routes.Methods("GET,HEAD", "/captcha/*", append(mid, captcha.Captchaer(context.GetImageCaptcha()))...)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if setting.Metrics.Enabled {
|
if setting.Metrics.Enabled {
|
||||||
|
|||||||
+10
-36
@@ -5,65 +5,37 @@ package context
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"image/color"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"gitea.dev/modules/cache"
|
|
||||||
"gitea.dev/modules/hcaptcha"
|
"gitea.dev/modules/hcaptcha"
|
||||||
|
"gitea.dev/modules/imagecaptcha"
|
||||||
"gitea.dev/modules/log"
|
"gitea.dev/modules/log"
|
||||||
"gitea.dev/modules/mcaptcha"
|
"gitea.dev/modules/mcaptcha"
|
||||||
"gitea.dev/modules/recaptcha"
|
"gitea.dev/modules/recaptcha"
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
"gitea.dev/modules/templates"
|
"gitea.dev/modules/templates"
|
||||||
"gitea.dev/modules/turnstile"
|
"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 (
|
const (
|
||||||
|
imageCaptchaIDField = "captcha_id"
|
||||||
|
imageCaptchaAnswerField = "captcha"
|
||||||
gRecaptchaResponseField = "g-recaptcha-response"
|
gRecaptchaResponseField = "g-recaptcha-response"
|
||||||
hCaptchaResponseField = "h-captcha-response"
|
hCaptchaResponseField = "h-captcha-response"
|
||||||
mCaptchaResponseField = "mcaptcha__token" // this form key is hard-coded in the mcaptcha frontend library
|
mCaptchaResponseField = "mcaptcha__token" // this form key is hard-coded in the mcaptcha frontend library
|
||||||
cfTurnstileResponseField = "cf-turnstile-response"
|
cfTurnstileResponseField = "cf-turnstile-response"
|
||||||
)
|
)
|
||||||
|
|
||||||
// VerifyCaptcha verifies Captcha data
|
// VerifyCaptcha returns whether the captcha is solved or disabled, otherwise it renders tpl with an error
|
||||||
// No-op if captchas are not enabled
|
func VerifyCaptcha(ctx *Context, tpl templates.TplName, form any) bool {
|
||||||
func VerifyCaptcha(ctx *Context, tpl templates.TplName, form any) {
|
|
||||||
if !setting.Service.EnableCaptcha {
|
if !setting.Service.EnableCaptcha {
|
||||||
return
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
var valid bool
|
var valid bool
|
||||||
var err error
|
var err error
|
||||||
switch setting.Service.CaptchaType {
|
switch setting.Service.CaptchaType {
|
||||||
case setting.ImageCaptcha:
|
case setting.ImageCaptcha:
|
||||||
valid = GetImageCaptcha().VerifyReq(ctx.Req)
|
valid = imagecaptcha.Verify(ctx.FormString(imageCaptchaIDField), ctx.FormString(imageCaptchaAnswerField))
|
||||||
case setting.ReCaptcha:
|
case setting.ReCaptcha:
|
||||||
valid, err = recaptcha.Verify(ctx, ctx.Req.Form.Get(gRecaptchaResponseField))
|
valid, err = recaptcha.Verify(ctx, ctx.Req.Form.Get(gRecaptchaResponseField))
|
||||||
case setting.HCaptcha:
|
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))
|
valid, err = turnstile.Verify(ctx, ctx.Req.Form.Get(cfTurnstileResponseField))
|
||||||
default:
|
default:
|
||||||
ctx.ServerError("Unknown Captcha Type", fmt.Errorf("unknown Captcha Type: %s", setting.Service.CaptchaType))
|
ctx.ServerError("Unknown Captcha Type", fmt.Errorf("unknown Captcha Type: %s", setting.Service.CaptchaType))
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Debug("Captcha Verify failed: %v", err)
|
log.Debug("Captcha Verify failed: %v", err)
|
||||||
@@ -83,5 +55,7 @@ func VerifyCaptcha(ctx *Context, tpl templates.TplName, form any) {
|
|||||||
if !valid {
|
if !valid {
|
||||||
ctx.Data["Err_Captcha"] = true
|
ctx.Data["Err_Captcha"] = true
|
||||||
ctx.RenderWithErrDeprecated(ctx.Tr("form.captcha_incorrect"), tpl, form)
|
ctx.RenderWithErrDeprecated(ctx.Tr("form.captcha_incorrect"), tpl, form)
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
{{if .EnableCaptcha}}{{if eq .CaptchaType "image"}}
|
{{if .EnableCaptcha}}{{if eq .CaptchaType "image"}}
|
||||||
|
{{$captchaID := call .CreateImageCaptcha}}
|
||||||
<div class="inline field tw-text-center">
|
<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>
|
||||||
<div class="required field {{if .Err_Captcha}}error{{end}}">
|
<div class="required field {{if .Err_Captcha}}error{{end}}">
|
||||||
<label for="captcha">{{ctx.Locale.Tr "captcha"}}</label>
|
<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>
|
</div>
|
||||||
{{else if eq .CaptchaType "recaptcha"}}
|
{{else if eq .CaptchaType "recaptcha"}}
|
||||||
<div class="inline field tw-text-center required">
|
<div class="inline field tw-text-center required">
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ import (
|
|||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
"gitea.dev/models/unittest"
|
"gitea.dev/models/unittest"
|
||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
|
"gitea.dev/modules/cache"
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
"gitea.dev/modules/test"
|
"gitea.dev/modules/test"
|
||||||
"gitea.dev/modules/translation"
|
"gitea.dev/modules/translation"
|
||||||
|
"gitea.dev/routers"
|
||||||
"gitea.dev/tests"
|
"gitea.dev/tests"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -35,6 +37,35 @@ func TestSignup(t *testing.T) {
|
|||||||
// should be able to view new user's page
|
// should be able to view new user's page
|
||||||
req = NewRequest(t, "GET", "/exampleUser")
|
req = NewRequest(t, "GET", "/exampleUser")
|
||||||
MakeRequest(t, req, http.StatusOK)
|
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) {
|
func TestSignupAsRestricted(t *testing.T) {
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import {isDarkTheme} from '../utils.ts';
|
import {isDarkTheme} from '../utils.ts';
|
||||||
|
import {registerGlobalEventFunc} from '../modules/observer.ts';
|
||||||
|
|
||||||
export async function initCaptcha() {
|
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');
|
const captchaEl = document.querySelector('#captcha');
|
||||||
if (!captchaEl) return;
|
if (!captchaEl) return;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user