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
|
||
|
|
}
|