Files
Qianandqian 501726b831 优化用户相关功能 (#8)
* 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>
2026-06-17 22:47:26 +08:00

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
}