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 }