Files

300 lines
10 KiB
Go
Raw Permalink Normal View History

package onboarding
import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"image"
"image/jpeg"
"image/png"
"math/big"
"strconv"
"strings"
"sync"
"time"
"github.com/jackc/pgx/v5"
"github.com/redis/go-redis/v9"
background1 "github.com/wenlng/go-captcha-assets/bindata/images/image_v2_1"
background3 "github.com/wenlng/go-captcha-assets/bindata/images/image_v2_3"
background5 "github.com/wenlng/go-captcha-assets/bindata/images/image_v2_5"
"github.com/wenlng/go-captcha-assets/helper"
"github.com/wenlng/go-captcha-assets/resources/tiles"
"github.com/wenlng/go-captcha/v2/slide"
"golang.org/x/image/draw"
)
const slideWidth, slideHeight, slideTileSize, slidePadding = 320, 180, 52, 3
// Upstream v2.0.5 Generate uses math/rand for target coordinates. Use its public
// drawing API with crypto/rand coordinates instead. Each render owns its draw
// instance; decoded Apache-2.0 assets are immutable and loaded only once.
var slideResources struct {
once sync.Once
backgrounds []image.Image
graphs []*tiles.GraphImage
err error
}
var slideWorkers = make(chan struct{}, 2)
func secureSlideInt(min, max int) (int, error) {
n, err := rand.Int(rand.Reader, big.NewInt(int64(max-min+1)))
if err != nil {
return 0, err
}
return min + int(n.Int64()), nil
}
type slideSolution struct{ X, Y int }
// Put two identical decoy gaps on the same track. Only the actual target supplies
// the tile pixels; all positions use crypto/rand and gaps cannot overlap.
func slideGapPositions(target slideSolution) ([]int, error) {
positions := []int{target.X}
for len(positions) < 3 {
var choices []int
for x := 10; x <= slideWidth-slideTileSize-10; x++ {
valid := true
for _, existing := range positions {
if x-existing < slideTileSize+10 && existing-x < slideTileSize+10 {
valid = false
break
}
}
if valid {
choices = append(choices, x)
}
}
if len(choices) == 0 {
return nil, errors.New("no slide decoy position available")
}
index, err := secureSlideInt(0, len(choices)-1)
if err != nil {
return nil, err
}
positions = append(positions, choices[index])
}
return positions, nil
}
func renderSlideCaptcha() (map[string]any, slideSolution, error) {
select {
case slideWorkers <- struct{}{}:
defer func() { <-slideWorkers }()
default:
return nil, slideSolution{}, fail(503, "CAPTCHA_BUSY", "验证服务繁忙,请稍后重试")
}
slideResources.once.Do(func() {
loaders := []struct {
load func(string) ([]byte, error)
path string
}{
{background1.Asset, "sourcedata/images/image-v2-1/image.jpg"},
{background3.Asset, "sourcedata/images/image-v2-3/image.jpg"},
{background5.Asset, "sourcedata/images/image-v2-5/image.jpg"},
}
for _, loader := range loaders {
raw, err := loader.load(loader.path)
if err != nil {
slideResources.err = err
return
}
img, err := helper.DecodeByteToJpeg(raw)
if err != nil {
slideResources.err = err
return
}
// Exact-size backgrounds avoid upstream's non-cryptographic crop RNG.
resized := image.NewNRGBA(image.Rect(0, 0, slideWidth, slideHeight))
draw.ApproxBiLinear.Scale(resized, resized.Bounds(), img, img.Bounds(), draw.Src, nil)
slideResources.backgrounds = append(slideResources.backgrounds, resized)
}
slideResources.graphs, slideResources.err = tiles.GetTiles()
})
if slideResources.err != nil {
return nil, slideSolution{}, slideResources.err
}
bg, err := secureSlideInt(0, len(slideResources.backgrounds)-1)
if err != nil {
return nil, slideSolution{}, err
}
graph, err := secureSlideInt(0, len(slideResources.graphs)-1)
if err != nil {
return nil, slideSolution{}, err
}
x, err := secureSlideInt(80, slideWidth-slideTileSize-10)
if err != nil {
return nil, slideSolution{}, err
}
y, err := secureSlideInt(10, slideHeight-slideTileSize-10)
if err != nil {
return nil, slideSolution{}, err
}
g := slideResources.graphs[graph]
positions, err := slideGapPositions(slideSolution{X: x, Y: y})
if err != nil {
return nil, slideSolution{}, err
}
blocks := make([]*slide.DrawBlock, 0, len(positions))
for _, gapX := range positions {
blocks = append(blocks, &slide.DrawBlock{X: gapX, Y: y, Width: slideTileSize, Height: slideTileSize, Image: g.ShadowImage})
}
d := slide.NewDrawImage()
master, bgImage, err := d.DrawWithNRGBA(&slide.DrawImageParams{
Width: slideWidth, Height: slideHeight, Background: slideResources.backgrounds[bg],
CaptchaDrawBlocks: blocks,
})
if err != nil {
return nil, slideSolution{}, err
}
tile, err := d.DrawWithTemplate(&slide.DrawTplImageParams{
Width: slideTileSize, Height: slideTileSize, Background: bgImage, MaskImage: g.MaskImage,
CaptchaDrawBlock: &slide.DrawBlock{X: x, Y: y, Width: slideTileSize, Height: slideTileSize, Image: g.OverlayImage},
})
if err != nil {
return nil, slideSolution{}, err
}
var mainBuf, tileBuf bytes.Buffer
if err = jpeg.Encode(&mainBuf, master, &jpeg.Options{Quality: 85}); err != nil {
return nil, slideSolution{}, err
}
if err = png.Encode(&tileBuf, tile); err != nil {
return nil, slideSolution{}, err
}
return map[string]any{
"mode": "slide", "image": "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(mainBuf.Bytes()),
"thumb_image": "data:image/png;base64," + base64.StdEncoding.EncodeToString(tileBuf.Bytes()),
"image_width": slideWidth, "image_height": slideHeight, "thumb_width": slideTileSize, "thumb_height": slideTileSize,
"thumb_x": 5, "thumb_y": y,
}, slideSolution{X: x, Y: y}, nil
}
func slideAAD(sessionHash string, stored storedCaptcha) string {
return "SLIDE-SOLUTION1\n" + sessionHash + "\n" + stored.ID + "\n" + stored.Client + "\n" + strconv.FormatInt(stored.Version, 10)
}
func (s *Service) slideProofHash(sessionHash, id, ticket string) string {
return domainMAC(s.crypt.hashKey, "SLIDE-PROOF1\n"+sessionHash+"\n"+id+"\n"+ticket)
}
func (s *Service) newSlideCaptcha(ctx context.Context, sessionHash string, stored *storedCaptcha) (map[string]any, error) {
if stored.Client != "saas" {
return nil, captchaInvalid()
}
out, solution, err := renderSlideCaptcha()
if err != nil {
return nil, err
}
raw, _ := json.Marshal(solution)
stored.Solution = s.crypt.seal(raw, slideAAD(sessionHash, *stored))
stored.ExpiresAt = s.now().Add(2 * time.Minute).UnixMilli()
if err = s.redis.Set(ctx, s.captchaKey(sessionHash), captchaJSON(*stored), 2*time.Minute).Err(); err != nil {
return nil, captchaUnavailable()
}
out["captcha_id"], out["expires_at_ms"] = stored.ID, stored.ExpiresAt
return out, nil
}
// Compare the entire old record so a delayed answer/proof never overwrites or
// deletes a newly refreshed challenge. Preserve Redis's original deadline.
const replaceSlideRecord = `if redis.call('GET',KEYS[1])~=ARGV[1] then return 0 end
local ttl=redis.call('PTTL',KEYS[1]); if ttl<=0 then return 0 end
redis.call('SET',KEYS[1],ARGV[2],'PX',ttl,'XX'); return 1`
const deleteCaptchaRecord = `if redis.call('GET',KEYS[1])~=ARGV[1] then return 0 end
return redis.call('DEL',KEYS[1])`
func (s *Service) consumeCaptcha(ctx context.Context, sessionHash, id string) (string, error) {
value, err := s.redis.Get(ctx, s.captchaKey(sessionHash)).Result()
if err != nil {
return "", err
}
var stored storedCaptcha
if json.Unmarshal([]byte(value), &stored) == nil {
if stored.ID != id {
return "", redis.Nil
}
} else if !strings.HasPrefix(value, id+":") {
return "", redis.Nil
}
removed, err := s.redis.Eval(ctx, deleteCaptchaRecord, []string{s.captchaKey(sessionHash)}, value).Int64()
if err != nil {
return "", err
}
if removed != 1 {
return "", redis.Nil
}
return value, nil
}
type SlideCaptchaVerifyInput struct {
ID string `json:"captcha_id"`
X *int `json:"x"`
Y *int `json:"y"`
}
func (s *Service) verifySlideCaptcha(ctx context.Context, sessionHash string, in SlideCaptchaVerifyInput, check func(pgx.Tx) error) (map[string]any, error) {
if !uuidV4(in.ID) || in.X == nil || in.Y == nil {
return nil, fail(400, "VALIDATION_FAILED", "请提交完整的拼图位置")
}
if err := s.limit(ctx, "captcha-verify:session:"+sessionHash, 30, 10*time.Minute); err != nil {
return nil, err
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
if err = check(tx); err != nil {
return nil, err
}
policy, err := s.readCaptchaPolicy(ctx, tx)
if err != nil {
return nil, captchaUnavailable()
}
client := captchaRequest(ctx).client
if client != "saas" || policy.mode(client) != "slide" {
return nil, captchaInvalid()
}
value, err := s.redis.Get(ctx, s.captchaKey(sessionHash)).Result()
if err != nil {
if errors.Is(err, redis.Nil) {
return nil, captchaInvalid()
}
return nil, captchaUnavailable()
}
var stored storedCaptcha
if json.Unmarshal([]byte(value), &stored) != nil || stored.ID != in.ID || stored.Client != client || stored.Mode != "slide" || stored.Version != policy.Version || stored.ExpiresAt <= s.now().UnixMilli() || len(stored.Solution) == 0 || stored.ProofHash != "" {
return nil, captchaInvalid()
}
plain, err := s.crypt.open(stored.Solution, slideAAD(sessionHash, stored))
if err != nil {
return nil, captchaInvalid()
}
var solution slideSolution
if json.Unmarshal(plain, &solution) != nil {
return nil, captchaInvalid()
}
if *in.X < 0 || *in.Y < 0 || *in.X > slideWidth-slideTileSize || *in.Y > slideHeight-slideTileSize || !slide.Validate(*in.X, *in.Y, solution.X, solution.Y, slidePadding) {
if err = s.redis.Eval(ctx, deleteCaptchaRecord, []string{s.captchaKey(sessionHash)}, value).Err(); err != nil {
return nil, captchaUnavailable()
}
return nil, captchaInvalid()
}
ticket := randomToken(32)
stored.Solution = nil
stored.ProofHash = s.slideProofHash(sessionHash, stored.ID, ticket)
replaced, err := s.redis.Eval(ctx, replaceSlideRecord, []string{s.captchaKey(sessionHash)}, value, captchaJSON(stored)).Int64()
if err != nil {
return nil, captchaUnavailable()
}
if replaced != 1 {
return nil, captchaInvalid()
}
if err = tx.Commit(ctx); err != nil {
return nil, err
}
return map[string]any{"captcha_id": stored.ID, "captcha_ticket": ticket, "expires_at_ms": stored.ExpiresAt}, nil
}