ci / verify (push) Canceled after 0s
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.
300 lines
10 KiB
Go
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
|
|
}
|