Files
story/internal/onboarding/local_slide_captcha.go
Codex Local bf1233d5df
ci / verify (push) Canceled after 0s
feat: expand companion platform, device controls, and memoir workflows
Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
2026-10-08 10:06:44 +08:00

300 lines
10 KiB
Go

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
}