516 lines
13 KiB
Go
516 lines
13 KiB
Go
package captcha
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"fmt"
|
||
"image"
|
||
"image/color"
|
||
"image/png"
|
||
"math"
|
||
"math/rand"
|
||
"time"
|
||
|
||
"github.com/google/uuid"
|
||
"github.com/redis/go-redis/v9"
|
||
)
|
||
|
||
const (
|
||
sliderBgWidth = 560
|
||
sliderBgHeight = 280
|
||
sliderBlockSize = 100
|
||
sliderMinX = 140
|
||
sliderMaxX = 420
|
||
sliderTolerance = 6
|
||
sliderExpiry = 5 * time.Minute
|
||
sliderTokenExpiry = 30 * time.Second
|
||
)
|
||
|
||
type sliderShape int
|
||
|
||
const (
|
||
shapeSquare sliderShape = 0
|
||
shapeCircle sliderShape = 1
|
||
shapeDiamond sliderShape = 2
|
||
shapeStar sliderShape = 3
|
||
shapeTriangle sliderShape = 4
|
||
shapeTrapezoid sliderShape = 5
|
||
)
|
||
|
||
type sliderService struct {
|
||
redis *redis.Client
|
||
}
|
||
|
||
func newSliderService(redisClient *redis.Client) *sliderService {
|
||
return &sliderService{redis: redisClient}
|
||
}
|
||
|
||
// sliderData stores the correct position and shape in Redis
|
||
type sliderData struct {
|
||
X int `json:"x"`
|
||
Y int `json:"y"`
|
||
Shape sliderShape `json:"shape"`
|
||
}
|
||
|
||
// inMask returns true if pixel (dx,dy) within the block bounding box belongs to the shape
|
||
func inMask(dx, dy int, shape sliderShape) bool {
|
||
half := sliderBlockSize / 2
|
||
switch shape {
|
||
case shapeCircle:
|
||
ex := dx - half
|
||
ey := dy - half
|
||
return ex*ex+ey*ey <= half*half
|
||
case shapeDiamond:
|
||
return abs(dx-half)+abs(dy-half) <= half
|
||
case shapeStar:
|
||
return inStar(dx, dy, half)
|
||
case shapeTriangle:
|
||
return inTriangle(dx, dy)
|
||
case shapeTrapezoid:
|
||
return inTrapezoid(dx, dy)
|
||
default: // shapeSquare
|
||
margin := 8
|
||
return dx >= margin && dx < sliderBlockSize-margin && dy >= margin && dy < sliderBlockSize-margin
|
||
}
|
||
}
|
||
|
||
func abs(v int) int {
|
||
if v < 0 {
|
||
return -v
|
||
}
|
||
return v
|
||
}
|
||
|
||
// pointInPolygon uses ray-casting to test if (x,y) is inside the polygon defined by pts.
|
||
func pointInPolygon(x, y float64, pts [][2]float64) bool {
|
||
n := len(pts)
|
||
inside := false
|
||
j := n - 1
|
||
for i := 0; i < n; i++ {
|
||
xi, yi := pts[i][0], pts[i][1]
|
||
xj, yj := pts[j][0], pts[j][1]
|
||
if ((yi > y) != (yj > y)) && (x < (xj-xi)*(y-yi)/(yj-yi)+xi) {
|
||
inside = !inside
|
||
}
|
||
j = i
|
||
}
|
||
return inside
|
||
}
|
||
|
||
// inStar returns true if (dx,dy) is inside a 5-pointed star centered in the block.
|
||
func inStar(dx, dy, half int) bool {
|
||
cx, cy := float64(half), float64(half)
|
||
r1 := float64(half) * 0.92 // outer radius
|
||
r2 := float64(half) * 0.40 // inner radius
|
||
x := float64(dx) - cx
|
||
y := float64(dy) - cy
|
||
pts := make([][2]float64, 10)
|
||
for i := 0; i < 10; i++ {
|
||
angle := float64(i)*math.Pi/5 - math.Pi/2
|
||
r := r1
|
||
if i%2 == 1 {
|
||
r = r2
|
||
}
|
||
pts[i] = [2]float64{r * math.Cos(angle), r * math.Sin(angle)}
|
||
}
|
||
return pointInPolygon(x, y, pts)
|
||
}
|
||
|
||
// inTriangle returns true if (dx,dy) is inside an upward-pointing triangle.
|
||
func inTriangle(dx, dy int) bool {
|
||
margin := 5
|
||
size := sliderBlockSize - 2*margin
|
||
half := float64(sliderBlockSize) / 2
|
||
ax, ay := half, float64(margin)
|
||
bx, by := float64(margin), float64(margin+size)
|
||
cx, cy2 := float64(margin+size), float64(margin+size)
|
||
px, py := float64(dx), float64(dy)
|
||
d1 := (px-bx)*(ay-by) - (ax-bx)*(py-by)
|
||
d2 := (px-cx)*(by-cy2) - (bx-cx)*(py-cy2)
|
||
d3 := (px-ax)*(cy2-ay) - (cx-ax)*(py-ay)
|
||
hasNeg := (d1 < 0) || (d2 < 0) || (d3 < 0)
|
||
hasPos := (d1 > 0) || (d2 > 0) || (d3 > 0)
|
||
return !(hasNeg && hasPos)
|
||
}
|
||
|
||
// inTrapezoid returns true if (dx,dy) is inside a trapezoid (wider at bottom).
|
||
func inTrapezoid(dx, dy int) bool {
|
||
margin := 5
|
||
topY := float64(margin)
|
||
bottomY := float64(sliderBlockSize - margin)
|
||
totalH := bottomY - topY
|
||
half := float64(sliderBlockSize) / 2
|
||
topHalfW := float64(sliderBlockSize) * 0.25
|
||
bottomHalfW := float64(sliderBlockSize) * 0.45
|
||
x, y := float64(dx), float64(dy)
|
||
if y < topY || y > bottomY {
|
||
return false
|
||
}
|
||
t := (y - topY) / totalH
|
||
hw := topHalfW + t*(bottomHalfW-topHalfW)
|
||
return x >= half-hw && x <= half+hw
|
||
}
|
||
|
||
func (s *sliderService) GenerateSlider(ctx context.Context) (id string, bgImage string, blockImage string, err error) {
|
||
bg := generateBackground()
|
||
|
||
r := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||
x := sliderMinX + r.Intn(sliderMaxX-sliderMinX)
|
||
y := r.Intn(sliderBgHeight - sliderBlockSize)
|
||
shape := sliderShape(r.Intn(6))
|
||
|
||
block := cropBlockShaped(bg, x, y, shape)
|
||
cutBackgroundShaped(bg, x, y, shape)
|
||
|
||
bgB64, err := imageToPNGBase64(bg)
|
||
if err != nil {
|
||
return "", "", "", err
|
||
}
|
||
blockB64, err := imageToPNGBase64(block)
|
||
if err != nil {
|
||
return "", "", "", err
|
||
}
|
||
|
||
id = uuid.New().String()
|
||
data, _ := json.Marshal(sliderData{X: x, Y: y, Shape: shape})
|
||
key := fmt.Sprintf("captcha:slider:%s", id)
|
||
if err = s.redis.Set(ctx, key, string(data), sliderExpiry).Err(); err != nil {
|
||
return "", "", "", err
|
||
}
|
||
|
||
return id, bgB64, blockB64, nil
|
||
}
|
||
|
||
func (s *sliderService) Generate(ctx context.Context) (id string, image string, err error) {
|
||
id, _, _, err = s.GenerateSlider(ctx)
|
||
return id, "", err
|
||
}
|
||
|
||
// TrailPoint records a pointer position and timestamp during drag
|
||
type TrailPoint struct {
|
||
X int `json:"x"`
|
||
Y int `json:"y"`
|
||
T int64 `json:"t"` // milliseconds since drag start
|
||
}
|
||
|
||
// validateTrail performs human-behaviour checks on the drag trail.
|
||
//
|
||
// Rules:
|
||
// 1. Trail must be provided and have >= 8 points
|
||
// 2. Total drag duration: 300ms – 15000ms
|
||
// 3. First point x <= 10 (started from left)
|
||
// 4. No single-step jump > 80px
|
||
// 5. Final x within tolerance*2 of declared x
|
||
// 6. Speed variance > 0 (not perfectly uniform / robotic)
|
||
// 7. Y-axis total deviation >= 2px (path is not a perfect horizontal line)
|
||
func validateTrail(trail []TrailPoint, declaredX int) bool {
|
||
if len(trail) < 8 {
|
||
return false
|
||
}
|
||
|
||
duration := trail[len(trail)-1].T - trail[0].T
|
||
if duration < 300 || duration > 15000 {
|
||
return false
|
||
}
|
||
|
||
if trail[0].X > 10 {
|
||
return false
|
||
}
|
||
|
||
// Collect per-step speeds and check max jump
|
||
var speeds []float64
|
||
for i := 1; i < len(trail); i++ {
|
||
dt := float64(trail[i].T - trail[i-1].T)
|
||
dx := float64(trail[i].X - trail[i-1].X)
|
||
dy := float64(trail[i].Y - trail[i-1].Y)
|
||
if abs(int(dx)) > 80 {
|
||
return false
|
||
}
|
||
if dt > 0 {
|
||
dist := math.Sqrt(dx*dx + dy*dy)
|
||
speeds = append(speeds, dist/dt)
|
||
}
|
||
}
|
||
|
||
// Speed variance check – robot drag tends to be perfectly uniform
|
||
if len(speeds) >= 3 {
|
||
mean := 0.0
|
||
for _, v := range speeds {
|
||
mean += v
|
||
}
|
||
mean /= float64(len(speeds))
|
||
variance := 0.0
|
||
for _, v := range speeds {
|
||
d := v - mean
|
||
variance += d * d
|
||
}
|
||
variance /= float64(len(speeds))
|
||
// If variance is essentially 0, it's robotic
|
||
if variance < 1e-6 {
|
||
return false
|
||
}
|
||
}
|
||
|
||
// Y-axis deviation: humans almost always move slightly on Y
|
||
minY := trail[0].Y
|
||
maxY := trail[0].Y
|
||
for _, p := range trail {
|
||
if p.Y < minY {
|
||
minY = p.Y
|
||
}
|
||
if p.Y > maxY {
|
||
maxY = p.Y
|
||
}
|
||
}
|
||
if maxY-minY < 2 {
|
||
return false
|
||
}
|
||
|
||
// Final position check
|
||
lastX := trail[len(trail)-1].X
|
||
if diff := abs(lastX - declaredX); diff > sliderTolerance*2 {
|
||
return false
|
||
}
|
||
|
||
return true
|
||
}
|
||
|
||
func (s *sliderService) VerifySlider(ctx context.Context, id string, x, y int, trail string) (token string, err error) {
|
||
// Trail is mandatory
|
||
if trail == "" {
|
||
return "", fmt.Errorf("trail required")
|
||
}
|
||
var points []TrailPoint
|
||
if jsonErr := json.Unmarshal([]byte(trail), &points); jsonErr != nil {
|
||
return "", fmt.Errorf("invalid trail")
|
||
}
|
||
if !validateTrail(points, x) {
|
||
return "", fmt.Errorf("trail validation failed")
|
||
}
|
||
|
||
key := fmt.Sprintf("captcha:slider:%s", id)
|
||
val, err := s.redis.Get(ctx, key).Result()
|
||
if err != nil {
|
||
return "", fmt.Errorf("captcha not found or expired")
|
||
}
|
||
|
||
var data sliderData
|
||
if err = json.Unmarshal([]byte(val), &data); err != nil {
|
||
return "", fmt.Errorf("invalid captcha data")
|
||
}
|
||
|
||
diffX := abs(x - data.X)
|
||
diffY := abs(y - data.Y)
|
||
if diffX > sliderTolerance || diffY > sliderTolerance {
|
||
s.redis.Del(ctx, key)
|
||
return "", fmt.Errorf("position mismatch")
|
||
}
|
||
|
||
s.redis.Del(ctx, key)
|
||
|
||
sliderToken := uuid.New().String()
|
||
tokenKey := fmt.Sprintf("captcha:slider:token:%s", sliderToken)
|
||
if err = s.redis.Set(ctx, tokenKey, "1", sliderTokenExpiry).Err(); err != nil {
|
||
return "", err
|
||
}
|
||
|
||
return sliderToken, nil
|
||
}
|
||
|
||
func (s *sliderService) VerifySliderToken(ctx context.Context, token string) (bool, error) {
|
||
if token == "" {
|
||
return false, nil
|
||
}
|
||
tokenKey := fmt.Sprintf("captcha:slider:token:%s", token)
|
||
val, err := s.redis.Get(ctx, tokenKey).Result()
|
||
if err != nil {
|
||
return false, nil
|
||
}
|
||
if val != "1" {
|
||
return false, nil
|
||
}
|
||
s.redis.Del(ctx, tokenKey)
|
||
return true, nil
|
||
}
|
||
|
||
func (s *sliderService) Verify(ctx context.Context, token string, code string, ip string) (bool, error) {
|
||
return s.VerifySliderToken(ctx, token)
|
||
}
|
||
|
||
func (s *sliderService) GetType() CaptchaType {
|
||
return CaptchaTypeSlider
|
||
}
|
||
|
||
// cropBlockShaped copies pixels within the shape mask from bg into a new block image.
|
||
// Pixels outside the mask are transparent. A 2-pixel white border is drawn along the shape edge.
|
||
func cropBlockShaped(bg *image.NRGBA, x, y int, shape sliderShape) *image.NRGBA {
|
||
block := image.NewNRGBA(image.Rect(0, 0, sliderBlockSize, sliderBlockSize))
|
||
for dy := 0; dy < sliderBlockSize; dy++ {
|
||
for dx := 0; dx < sliderBlockSize; dx++ {
|
||
if inMask(dx, dy, shape) {
|
||
block.SetNRGBA(dx, dy, bg.NRGBAAt(x+dx, y+dy))
|
||
}
|
||
}
|
||
}
|
||
|
||
// Draw 2-pixel bright border along shape edge
|
||
borderColor := color.NRGBA{R: 255, G: 255, B: 255, A: 230}
|
||
for dy := 0; dy < sliderBlockSize; dy++ {
|
||
for dx := 0; dx < sliderBlockSize; dx++ {
|
||
if !inMask(dx, dy, shape) {
|
||
continue
|
||
}
|
||
nearEdge := false
|
||
check:
|
||
for ddy := -2; ddy <= 2; ddy++ {
|
||
for ddx := -2; ddx <= 2; ddx++ {
|
||
if abs(ddx)+abs(ddy) > 2 {
|
||
continue
|
||
}
|
||
nx, ny := dx+ddx, dy+ddy
|
||
if nx < 0 || nx >= sliderBlockSize || ny < 0 || ny >= sliderBlockSize || !inMask(nx, ny, shape) {
|
||
nearEdge = true
|
||
break check
|
||
}
|
||
}
|
||
}
|
||
if nearEdge {
|
||
block.SetNRGBA(dx, dy, borderColor)
|
||
}
|
||
}
|
||
}
|
||
return block
|
||
}
|
||
|
||
// cutBackgroundShaped blanks the shape area and draws a border outline
|
||
func cutBackgroundShaped(bg *image.NRGBA, x, y int, shape sliderShape) {
|
||
holeColor := color.NRGBA{R: 0, G: 0, B: 0, A: 100}
|
||
borderColor := color.NRGBA{R: 255, G: 255, B: 255, A: 220}
|
||
|
||
// Fill hole
|
||
for dy := 0; dy < sliderBlockSize; dy++ {
|
||
for dx := 0; dx < sliderBlockSize; dx++ {
|
||
if inMask(dx, dy, shape) {
|
||
bg.SetNRGBA(x+dx, y+dy, holeColor)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Draw 2-pixel border along hole edge
|
||
for dy := 0; dy < sliderBlockSize; dy++ {
|
||
for dx := 0; dx < sliderBlockSize; dx++ {
|
||
if !inMask(dx, dy, shape) {
|
||
continue
|
||
}
|
||
nearEdge := false
|
||
check:
|
||
for ddy := -2; ddy <= 2; ddy++ {
|
||
for ddx := -2; ddx <= 2; ddx++ {
|
||
if abs(ddx)+abs(ddy) > 2 {
|
||
continue
|
||
}
|
||
nx, ny := dx+ddx, dy+ddy
|
||
if nx < 0 || nx >= sliderBlockSize || ny < 0 || ny >= sliderBlockSize || !inMask(nx, ny, shape) {
|
||
nearEdge = true
|
||
break check
|
||
}
|
||
}
|
||
}
|
||
if nearEdge {
|
||
bg.SetNRGBA(x+dx, y+dy, borderColor)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// generateBackground creates a colorful 320x160 background image
|
||
func generateBackground() *image.NRGBA {
|
||
r := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||
img := image.NewNRGBA(image.Rect(0, 0, sliderBgWidth, sliderBgHeight))
|
||
|
||
blockW := 60
|
||
blockH := 60
|
||
palette := []color.NRGBA{
|
||
{R: 70, G: 130, B: 180, A: 255},
|
||
{R: 60, G: 179, B: 113, A: 255},
|
||
{R: 205, G: 92, B: 92, A: 255},
|
||
{R: 255, G: 165, B: 0, A: 255},
|
||
{R: 147, G: 112, B: 219, A: 255},
|
||
{R: 64, G: 224, B: 208, A: 255},
|
||
{R: 220, G: 120, B: 60, A: 255},
|
||
{R: 100, G: 149, B: 237, A: 255},
|
||
}
|
||
|
||
for by := 0; by*blockH < sliderBgHeight; by++ {
|
||
for bx := 0; bx*blockW < sliderBgWidth; bx++ {
|
||
base := palette[r.Intn(len(palette))]
|
||
x0 := bx * blockW
|
||
y0 := by * blockH
|
||
x1 := x0 + blockW
|
||
y1 := y0 + blockH
|
||
for py := y0; py < y1 && py < sliderBgHeight; py++ {
|
||
for px := x0; px < x1 && px < sliderBgWidth; px++ {
|
||
v := int8(r.Intn(41) - 20)
|
||
img.SetNRGBA(px, py, color.NRGBA{
|
||
R: addVariation(base.R, v),
|
||
G: addVariation(base.G, v),
|
||
B: addVariation(base.B, v),
|
||
A: 255,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Add some random circles for visual complexity
|
||
numCircles := 6 + r.Intn(6)
|
||
for i := 0; i < numCircles; i++ {
|
||
cx := r.Intn(sliderBgWidth)
|
||
cy := r.Intn(sliderBgHeight)
|
||
radius := 18 + r.Intn(30)
|
||
circleColor := color.NRGBA{
|
||
R: uint8(r.Intn(256)),
|
||
G: uint8(r.Intn(256)),
|
||
B: uint8(r.Intn(256)),
|
||
A: 180,
|
||
}
|
||
drawCircle(img, cx, cy, radius, circleColor)
|
||
}
|
||
|
||
return img
|
||
}
|
||
|
||
func addVariation(base uint8, v int8) uint8 {
|
||
result := int(base) + int(v)
|
||
if result < 0 {
|
||
return 0
|
||
}
|
||
if result > 255 {
|
||
return 255
|
||
}
|
||
return uint8(result)
|
||
}
|
||
|
||
func drawCircle(img *image.NRGBA, cx, cy, radius int, c color.NRGBA) {
|
||
bounds := img.Bounds()
|
||
for y := cy - radius; y <= cy+radius; y++ {
|
||
for x := cx - radius; x <= cx+radius; x++ {
|
||
if (x-cx)*(x-cx)+(y-cy)*(y-cy) <= radius*radius {
|
||
if x >= bounds.Min.X && x < bounds.Max.X && y >= bounds.Min.Y && y < bounds.Max.Y {
|
||
img.SetNRGBA(x, y, c)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func imageToPNGBase64(img image.Image) (string, error) {
|
||
var buf bytes.Buffer
|
||
if err := png.Encode(&buf, img); err != nil {
|
||
return "", err
|
||
}
|
||
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()), nil
|
||
}
|