* improve(user):优化注册功能提示 * improve(user):优化邮箱大小写重复问题 * improve(user):添加密码强度指示器 * fix(setting):修复系统设置功能里面的数字转换问题 * feat(email):优化邮件发送功能 * chore(email):移除无用配置和日志 * fix(user):修复重置密码功能 * chore(ui):调整轮询间隔 * chore(ui):添加充值提示 * chore(ui):优化用户注册功能 * chore(sql):补充数据库变更文件 * fix(email):优化邮箱校验 --------- Co-authored-by: qian <wuchengqian5@163.com>
278 lines
8.9 KiB
Go
278 lines
8.9 KiB
Go
package common
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"time"
|
|
|
|
"github.com/gogf/gf/v2/frame/g"
|
|
"github.com/google/uuid"
|
|
"github.com/wenlng/go-captcha-assets/resources/imagesv2"
|
|
"github.com/wenlng/go-captcha-assets/resources/tiles"
|
|
"github.com/wenlng/go-captcha/v2/base/option"
|
|
"github.com/wenlng/go-captcha/v2/slide"
|
|
|
|
"github.com/qianfree/team-api/internal/consts"
|
|
"github.com/qianfree/team-api/internal/utility/turnstile"
|
|
)
|
|
|
|
const (
|
|
captchaRedisPrefix = "captcha"
|
|
captchaVerifiedPrefix = "captcha:verified"
|
|
captchaPadding = 5
|
|
captchaWidth = 320
|
|
captchaHeight = 180
|
|
)
|
|
|
|
var captchaBuilder slide.Builder
|
|
|
|
func init() {
|
|
graphImages, err := loadGraphImages()
|
|
if err != nil {
|
|
log.Fatalf("[Captcha] failed to load tile assets: %v", err)
|
|
}
|
|
|
|
backgroundImages, err := imagesv2.GetImages()
|
|
if err != nil {
|
|
log.Fatalf("[Captcha] failed to load background images: %v", err)
|
|
}
|
|
|
|
captchaBuilder = slide.NewBuilder(
|
|
slide.WithImageSize(option.Size{Width: captchaWidth, Height: captchaHeight}),
|
|
slide.WithRangeGraphAnglePos([]option.RangeVal{{Min: 0, Max: 15}}),
|
|
slide.WithRangeGraphSize(option.RangeVal{Min: 50, Max: 80}),
|
|
slide.WithGenGraphNumber(1),
|
|
)
|
|
captchaBuilder.SetResources(
|
|
slide.WithBackgrounds(backgroundImages),
|
|
slide.WithGraphImages(graphImages),
|
|
)
|
|
}
|
|
|
|
func loadGraphImages() ([]*slide.GraphImage, error) {
|
|
assetTiles, err := tiles.GetTiles()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load tiles: %w", err)
|
|
}
|
|
|
|
graphImages := make([]*slide.GraphImage, len(assetTiles))
|
|
for i, t := range assetTiles {
|
|
graphImages[i] = &slide.GraphImage{
|
|
OverlayImage: t.OverlayImage,
|
|
ShadowImage: t.ShadowImage,
|
|
MaskImage: t.MaskImage,
|
|
}
|
|
}
|
|
return graphImages, nil
|
|
}
|
|
|
|
// GenerateCaptcha creates a slider captcha, stores the answer in Redis, and returns the images.
|
|
func GenerateCaptcha(ctx context.Context) (*CaptchaGenerateResult, error) {
|
|
capt := captchaBuilder.Make()
|
|
captData, err := capt.Generate()
|
|
if err != nil {
|
|
g.Log().Errorf(ctx, "[Captcha] generate failed: %v", err)
|
|
return nil, NewBusinessError(consts.CodeInternalServerError, "验证码生成失败")
|
|
}
|
|
|
|
block := captData.GetData()
|
|
captchaKey := uuid.New().String()
|
|
|
|
expireSec := getCaptchaExpireSeconds(ctx)
|
|
redisKey := fmt.Sprintf("%s:state:%s", captchaRedisPrefix, captchaKey)
|
|
|
|
stateJSON, _ := json.Marshal(map[string]int{"x": block.X, "y": block.Y})
|
|
_, err = g.Redis().Do(ctx, "SETEX", redisKey, int64(expireSec.Seconds()), string(stateJSON))
|
|
if err != nil {
|
|
g.Log().Errorf(ctx, "[Captcha] redis set failed: %v", err)
|
|
return nil, NewBusinessError(consts.CodeInternalServerError, "验证码生成失败")
|
|
}
|
|
|
|
masterB64, err := captData.GetMasterImage().ToBase64Data()
|
|
if err != nil {
|
|
g.Log().Errorf(ctx, "[Captcha] master base64 failed: %v", err)
|
|
return nil, NewBusinessError(consts.CodeInternalServerError, "验证码生成失败")
|
|
}
|
|
|
|
tileB64, err := captData.GetTileImage().ToBase64Data()
|
|
if err != nil {
|
|
g.Log().Errorf(ctx, "[Captcha] tile base64 failed: %v", err)
|
|
return nil, NewBusinessError(consts.CodeInternalServerError, "验证码生成失败")
|
|
}
|
|
|
|
return &CaptchaGenerateResult{
|
|
CaptchaKey: captchaKey,
|
|
MasterImage: "data:image/jpeg;base64," + masterB64,
|
|
TileImage: "data:image/png;base64," + tileB64,
|
|
TileY: block.DY,
|
|
}, nil
|
|
}
|
|
|
|
// CaptchaGenerateResult holds the captcha generation response.
|
|
type CaptchaGenerateResult struct {
|
|
CaptchaKey string `json:"captcha_key"`
|
|
MasterImage string `json:"master_image"`
|
|
TileImage string `json:"tile_image"`
|
|
TileY int `json:"tile_y"`
|
|
}
|
|
|
|
// VerifyCaptcha checks if the user's slide X coordinate matches the stored answer.
|
|
// On success, sets a verified flag so CheckCaptchaRequired can pass without re-checking.
|
|
func VerifyCaptcha(ctx context.Context, captchaKey string, userX int) (*CaptchaVerifyResult, error) {
|
|
stateKey := fmt.Sprintf("%s:state:%s", captchaRedisPrefix, captchaKey)
|
|
verifiedKey := fmt.Sprintf("%s:%s", captchaVerifiedPrefix, captchaKey)
|
|
|
|
result, err := g.Redis().Do(ctx, "GET", stateKey)
|
|
if err != nil || result.IsNil() {
|
|
return &CaptchaVerifyResult{Verified: false}, nil
|
|
}
|
|
|
|
jsonStr := result.String()
|
|
if jsonStr == "" {
|
|
return &CaptchaVerifyResult{Verified: false}, nil
|
|
}
|
|
|
|
var stored struct {
|
|
X int `json:"x"`
|
|
Y int `json:"y"`
|
|
}
|
|
if err := json.Unmarshal([]byte(jsonStr), &stored); err != nil {
|
|
g.Log().Warningf(ctx, "[Captcha] unmarshal failed: %v", err)
|
|
return &CaptchaVerifyResult{Verified: false}, nil
|
|
}
|
|
|
|
verified := slide.Validate(stored.X, stored.Y, userX, stored.Y, captchaPadding)
|
|
|
|
if verified {
|
|
// Mark as verified so CheckCaptchaRequired can skip re-check
|
|
expireSec := getCaptchaExpireSeconds(ctx)
|
|
g.Redis().Do(ctx, "SETEX", verifiedKey, int64(expireSec.Seconds()), "1")
|
|
}
|
|
|
|
// One-time use: always delete the answer
|
|
_, _ = g.Redis().Do(ctx, "DEL", stateKey)
|
|
|
|
return &CaptchaVerifyResult{Verified: verified}, nil
|
|
}
|
|
|
|
// CaptchaVerifyResult holds the captcha verification response.
|
|
type CaptchaVerifyResult struct {
|
|
Verified bool `json:"verified"`
|
|
}
|
|
|
|
// CheckCaptchaRequired checks captcha verification. If pre-verified via VerifyCaptcha, passes directly.
|
|
func CheckCaptchaRequired(ctx context.Context, _ string, captchaKey string, captchaX int) error {
|
|
if captchaKey == "" {
|
|
return NewBusinessError(consts.CodeCaptchaRequired, consts.MsgCaptchaRequired)
|
|
}
|
|
|
|
// Check pre-verification flag first
|
|
verifiedKey := fmt.Sprintf("%s:%s", captchaVerifiedPrefix, captchaKey)
|
|
result, err := g.Redis().Do(ctx, "GET", verifiedKey)
|
|
if err == nil && !result.IsNil() {
|
|
// Already verified — consume the flag and pass
|
|
_, _ = g.Redis().Do(ctx, "DEL", verifiedKey)
|
|
return nil
|
|
}
|
|
|
|
// Fallback: direct verification (for non-pre-verified flow)
|
|
stateKey := fmt.Sprintf("%s:state:%s", captchaRedisPrefix, captchaKey)
|
|
result, err = g.Redis().Do(ctx, "GET", stateKey)
|
|
if err != nil || result.IsNil() {
|
|
return NewBusinessError(consts.CodeCaptchaFailed, "验证码已过期,请重新获取")
|
|
}
|
|
|
|
jsonStr := result.String()
|
|
var stored struct {
|
|
X int `json:"x"`
|
|
Y int `json:"y"`
|
|
}
|
|
if err := json.Unmarshal([]byte(jsonStr), &stored); err != nil {
|
|
g.Log().Warningf(ctx, "[Captcha] unmarshal failed: %v", err)
|
|
return NewBusinessError(consts.CodeCaptchaFailed, consts.MsgCaptchaFailed)
|
|
}
|
|
|
|
verified := slide.Validate(stored.X, stored.Y, captchaX, stored.Y, captchaPadding)
|
|
|
|
_, _ = g.Redis().Do(ctx, "DEL", stateKey)
|
|
|
|
if !verified {
|
|
return NewBusinessError(consts.CodeCaptchaFailed, consts.MsgCaptchaFailed)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// IsCaptchaVerified checks whether the slider captcha has been completed
|
|
// (the verified flag is set by VerifyCaptcha during the slide). Unlike
|
|
// CheckCaptchaRequired it does NOT consume the flag, so a subsequent
|
|
// consuming check (e.g. at password reset) still passes. Used to gate
|
|
// low-sensitivity actions like sending a reset code without burning the
|
|
// one-shot verification before the final sensitive operation.
|
|
func IsCaptchaVerified(ctx context.Context, captchaKey string, _ int) error {
|
|
if captchaKey == "" {
|
|
return NewBusinessError(consts.CodeCaptchaRequired, consts.MsgCaptchaRequired)
|
|
}
|
|
|
|
verifiedKey := fmt.Sprintf("%s:%s", captchaVerifiedPrefix, captchaKey)
|
|
result, err := g.Redis().Do(ctx, "GET", verifiedKey)
|
|
if err != nil || result.IsNil() {
|
|
// No verified flag → user never completed the slide (or it expired)
|
|
return NewBusinessError(consts.CodeCaptchaRequired, consts.MsgCaptchaRequired)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func getCaptchaExpireSeconds(ctx context.Context) time.Duration {
|
|
secs := Config().GetInt(ctx, "captcha_expire_seconds")
|
|
if secs < 60 {
|
|
secs = 300
|
|
}
|
|
return time.Duration(secs) * time.Second
|
|
}
|
|
|
|
// CheckTurnstileRequired checks Cloudflare Turnstile verification when enabled.
|
|
// If Turnstile is disabled, passes through (allows fallback to captcha).
|
|
func CheckTurnstileRequired(ctx context.Context, token string) error {
|
|
// Check if Turnstile is enabled
|
|
enabled := Config().GetBool(ctx, "turnstile_enabled")
|
|
if !enabled {
|
|
// Turnstile not enabled, allow pass-through to captcha
|
|
return nil
|
|
}
|
|
|
|
// Turnstile is enabled, token is required
|
|
if token == "" {
|
|
return NewBusinessError(consts.CodeCaptchaRequired, consts.MsgCaptchaRequired)
|
|
}
|
|
|
|
// Verify token with Cloudflare
|
|
secretKey := Config().GetString(ctx, "turnstile_secret_key")
|
|
if secretKey == "" {
|
|
g.Log().Errorf(ctx, "[Turnstile] secret_key not configured")
|
|
return NewBusinessError(consts.CodeInternalServerError, "验证服务配置错误")
|
|
}
|
|
|
|
// Get client IP for verification
|
|
ipAddress := ""
|
|
if r := g.RequestFromCtx(ctx); r != nil {
|
|
ipAddress = r.GetClientIp()
|
|
}
|
|
|
|
result, err := turnstile.Verify(ctx, secretKey, token, ipAddress)
|
|
if err != nil {
|
|
g.Log().Errorf(ctx, "[Turnstile] verification failed: %v", err)
|
|
return NewBusinessError(consts.CodeCaptchaFailed, "人机验证失败")
|
|
}
|
|
|
|
if !result.Success {
|
|
g.Log().Warningf(ctx, "[Turnstile] verification failed: error codes=%v", result.ErrorCodes)
|
|
return NewBusinessError(consts.CodeCaptchaFailed, "人机验证失败")
|
|
}
|
|
|
|
// Verification successful
|
|
return nil
|
|
}
|