From 7fe7243c2411e25531bc8aa0d8a2426c97ca7e57 Mon Sep 17 00:00:00 2001 From: EUForest Date: Mon, 23 Mar 2026 02:42:12 +0800 Subject: [PATCH] feat: Add slider verification code --- apis/auth/auth.api | 41 +- .../admin/adminSliderVerifyCaptchaHandler.go | 26 + .../auth/sliderVerifyCaptchaHandler.go | 26 + .../handler/auth/telephoneRegisterHandler.go | 26 + internal/handler/routes.go | 14 +- .../auth/admin/adminGenerateCaptchaLogic.go | 11 + internal/logic/auth/admin/adminLoginLogic.go | 57 +- .../auth/admin/adminResetPasswordLogic.go | 57 +- .../admin/adminSliderVerifyCaptchaLogic.go | 57 ++ internal/logic/auth/generateCaptchaLogic.go | 11 + internal/logic/auth/resetPasswordLogic.go | 58 +- .../logic/auth/sliderVerifyCaptchaLogic.go | 51 ++ internal/logic/auth/telephoneLoginLogic.go | 57 +- internal/logic/auth/telephoneRegisterLogic.go | 30 + .../logic/auth/telephoneUserRegisterLogic.go | 58 +- internal/logic/auth/userLoginLogic.go | 57 +- internal/logic/auth/userRegisterLogic.go | 57 +- internal/types/types.go | 24 +- pkg/captcha/service.go | 25 +- pkg/captcha/slider.go | 515 ++++++++++++++++++ pkg/captcha/verify.go | 78 +++ 21 files changed, 987 insertions(+), 349 deletions(-) create mode 100644 internal/handler/auth/admin/adminSliderVerifyCaptchaHandler.go create mode 100644 internal/handler/auth/sliderVerifyCaptchaHandler.go create mode 100644 internal/handler/auth/telephoneRegisterHandler.go create mode 100644 internal/logic/auth/admin/adminSliderVerifyCaptchaLogic.go create mode 100644 internal/logic/auth/sliderVerifyCaptchaLogic.go create mode 100644 internal/logic/auth/telephoneRegisterLogic.go create mode 100644 pkg/captcha/slider.go create mode 100644 pkg/captcha/verify.go diff --git a/apis/auth/auth.api b/apis/auth/auth.api index c1d3c8b..afafc96 100644 --- a/apis/auth/auth.api +++ b/apis/auth/auth.api @@ -20,6 +20,7 @@ type ( CfToken string `json:"cf_token,optional"` CaptchaId string `json:"captcha_id,optional"` CaptchaCode string `json:"captcha_code,optional"` + SliderToken string `json:"slider_token,optional"` } // Check user is exist request CheckUserRequest { @@ -42,6 +43,7 @@ type ( CfToken string `json:"cf_token,optional"` CaptchaId string `json:"captcha_id,optional"` CaptchaCode string `json:"captcha_code,optional"` + SliderToken string `json:"slider_token,optional"` } // User reset password request ResetPasswordRequest { @@ -55,6 +57,7 @@ type ( CfToken string `json:"cf_token,optional"` CaptchaId string `json:"captcha_id,optional"` CaptchaCode string `json:"captcha_code,optional"` + SliderToken string `json:"slider_token,optional"` } LoginResponse { Token string `json:"token"` @@ -83,6 +86,7 @@ type ( CfToken string `json:"cf_token,optional"` CaptchaId string `json:"captcha_id,optional"` CaptchaCode string `json:"captcha_code,optional"` + SliderToken string `json:"slider_token,optional"` } // Check user is exist request TelephoneCheckUserRequest { @@ -107,6 +111,7 @@ type ( CfToken string `json:"cf_token,optional"` CaptchaId string `json:"captcha_id,optional"` CaptchaCode string `json:"captcha_code,optional"` + SliderToken string `json:"slider_token,optional"` } // User login response TelephoneResetPasswordRequest { @@ -121,6 +126,7 @@ type ( CfToken string `json:"cf_token,optional"` CaptchaId string `json:"captcha_id,optional"` CaptchaCode string `json:"captcha_code,optional"` + SliderToken string `json:"slider_token,optional"` } AppleLoginCallbackRequest { Code string `form:"code"` @@ -139,9 +145,19 @@ type ( ShortCode string `json:"short_code,optional"` } GenerateCaptchaResponse { - Id string `json:"id"` - Image string `json:"image"` - Type string `json:"type"` + Id string `json:"id"` + Image string `json:"image"` + Type string `json:"type"` + BlockImage string `json:"block_image,omitempty"` + } + SliderVerifyCaptchaRequest { + Id string `json:"id" validate:"required"` + X int `json:"x" validate:"required"` + Y int `json:"y" validate:"required"` + Trail string `json:"trail"` + } + SliderVerifyCaptchaResponse { + Token string `json:"token"` } ) @@ -176,20 +192,24 @@ service ppanel { get /check/telephone (TelephoneCheckUserRequest) returns (TelephoneCheckUserResponse) @doc "User Telephone register" - @handler TelephoneUserRegister + @handler TelephoneRegister post /register/telephone (TelephoneRegisterRequest) returns (LoginResponse) - @doc "Reset password" + @doc "Reset password by telephone" @handler TelephoneResetPassword post /reset/telephone (TelephoneResetPasswordRequest) returns (LoginResponse) + @doc "Device Login" + @handler DeviceLogin + post /login/device (DeviceLoginRequest) returns (LoginResponse) + @doc "Generate captcha" @handler GenerateCaptcha post /captcha/generate returns (GenerateCaptchaResponse) - @doc "Device Login" - @handler DeviceLogin - post /login/device (DeviceLoginRequest) returns (LoginResponse) + @doc "Verify slider captcha" + @handler SliderVerifyCaptcha + post /captcha/slider/verify (SliderVerifyCaptchaRequest) returns (SliderVerifyCaptchaResponse) } @server ( @@ -209,6 +229,10 @@ service ppanel { @doc "Generate captcha" @handler AdminGenerateCaptcha post /captcha/generate returns (GenerateCaptchaResponse) + + @doc "Verify slider captcha" + @handler AdminSliderVerifyCaptcha + post /captcha/slider/verify (SliderVerifyCaptchaRequest) returns (SliderVerifyCaptchaResponse) } @server ( @@ -228,4 +252,3 @@ service ppanel { @handler AppleLoginCallback post /callback/apple (AppleLoginCallbackRequest) } - diff --git a/internal/handler/auth/admin/adminSliderVerifyCaptchaHandler.go b/internal/handler/auth/admin/adminSliderVerifyCaptchaHandler.go new file mode 100644 index 0000000..59f2f69 --- /dev/null +++ b/internal/handler/auth/admin/adminSliderVerifyCaptchaHandler.go @@ -0,0 +1,26 @@ +package admin + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/auth/admin" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +// Verify slider captcha +func AdminSliderVerifyCaptchaHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.SliderVerifyCaptchaRequest + _ = c.ShouldBind(&req) + validateErr := svcCtx.Validate(&req) + if validateErr != nil { + result.ParamErrorResult(c, validateErr) + return + } + + l := admin.NewAdminSliderVerifyCaptchaLogic(c.Request.Context(), svcCtx) + resp, err := l.AdminSliderVerifyCaptcha(&req) + result.HttpResult(c, resp, err) + } +} diff --git a/internal/handler/auth/sliderVerifyCaptchaHandler.go b/internal/handler/auth/sliderVerifyCaptchaHandler.go new file mode 100644 index 0000000..c873623 --- /dev/null +++ b/internal/handler/auth/sliderVerifyCaptchaHandler.go @@ -0,0 +1,26 @@ +package auth + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/auth" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +// Verify slider captcha +func SliderVerifyCaptchaHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.SliderVerifyCaptchaRequest + _ = c.ShouldBind(&req) + validateErr := svcCtx.Validate(&req) + if validateErr != nil { + result.ParamErrorResult(c, validateErr) + return + } + + l := auth.NewSliderVerifyCaptchaLogic(c.Request.Context(), svcCtx) + resp, err := l.SliderVerifyCaptcha(&req) + result.HttpResult(c, resp, err) + } +} diff --git a/internal/handler/auth/telephoneRegisterHandler.go b/internal/handler/auth/telephoneRegisterHandler.go new file mode 100644 index 0000000..7e4f355 --- /dev/null +++ b/internal/handler/auth/telephoneRegisterHandler.go @@ -0,0 +1,26 @@ +package auth + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/auth" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +// User Telephone register +func TelephoneRegisterHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.TelephoneRegisterRequest + _ = c.ShouldBind(&req) + validateErr := svcCtx.Validate(&req) + if validateErr != nil { + result.ParamErrorResult(c, validateErr) + return + } + + l := auth.NewTelephoneRegisterLogic(c.Request.Context(), svcCtx) + resp, err := l.TelephoneRegister(&req) + result.HttpResult(c, resp, err) + } +} diff --git a/internal/handler/routes.go b/internal/handler/routes.go index e3995be..7d8e760 100644 --- a/internal/handler/routes.go +++ b/internal/handler/routes.go @@ -674,6 +674,9 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) { // Generate captcha authGroupRouter.POST("/captcha/generate", auth.GenerateCaptchaHandler(serverCtx)) + // Verify slider captcha + authGroupRouter.POST("/captcha/slider/verify", auth.SliderVerifyCaptchaHandler(serverCtx)) + // Check user is exist authGroupRouter.GET("/check", auth.CheckUserHandler(serverCtx)) @@ -693,12 +696,12 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) { authGroupRouter.POST("/register", auth.UserRegisterHandler(serverCtx)) // User Telephone register - authGroupRouter.POST("/register/telephone", auth.TelephoneUserRegisterHandler(serverCtx)) + authGroupRouter.POST("/register/telephone", auth.TelephoneRegisterHandler(serverCtx)) // Reset password authGroupRouter.POST("/reset", auth.ResetPasswordHandler(serverCtx)) - // Reset password + // Reset password by telephone authGroupRouter.POST("/reset/telephone", auth.TelephoneResetPasswordHandler(serverCtx)) } @@ -709,6 +712,9 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) { // Generate captcha authAdminGroupRouter.POST("/captcha/generate", authAdmin.AdminGenerateCaptchaHandler(serverCtx)) + // Verify slider captcha + authAdminGroupRouter.POST("/captcha/slider/verify", authAdmin.AdminSliderVerifyCaptchaHandler(serverCtx)) + // Admin login authAdminGroupRouter.POST("/login", authAdmin.AdminLoginHandler(serverCtx)) @@ -1008,10 +1014,10 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) { serverGroupRouter.GET("/user", server.GetServerUserListHandler(serverCtx)) } - serverV2GroupRouter := router.Group("/v2/server") + serverGroupRouterV2 := router.Group("/v2/server") { // Get Server Protocol Config - serverV2GroupRouter.GET("/:server_id", server.QueryServerProtocolConfigHandler(serverCtx)) + serverGroupRouterV2.GET("/:server_id", server.QueryServerProtocolConfigHandler(serverCtx)) } } diff --git a/internal/logic/auth/admin/adminGenerateCaptchaLogic.go b/internal/logic/auth/admin/adminGenerateCaptchaLogic.go index 4772855..a45df89 100644 --- a/internal/logic/auth/admin/adminGenerateCaptchaLogic.go +++ b/internal/logic/auth/admin/adminGenerateCaptchaLogic.go @@ -64,6 +64,17 @@ func (l *AdminGenerateCaptchaLogic) AdminGenerateCaptcha() (resp *types.Generate } else if config.CaptchaType == "turnstile" { // For Turnstile, just return the site key resp.Id = config.TurnstileSiteKey + } else if config.CaptchaType == "slider" { + // For slider, generate background and block images + sliderSvc := captcha.NewSliderService(l.svcCtx.Redis) + id, bgImage, blockImage, err := sliderSvc.GenerateSlider(l.ctx) + if err != nil { + l.Logger.Error("[AdminGenerateCaptchaLogic] Generate slider captcha error: ", logger.Field("error", err.Error())) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "Generate slider captcha error: %v", err.Error()) + } + resp.Id = id + resp.Image = bgImage + resp.BlockImage = blockImage } return resp, nil diff --git a/internal/logic/auth/admin/adminLoginLogic.go b/internal/logic/auth/admin/adminLoginLogic.go index 2167eaf..72aed52 100644 --- a/internal/logic/auth/admin/adminLoginLogic.go +++ b/internal/logic/auth/admin/adminLoginLogic.go @@ -137,65 +137,28 @@ func (l *AdminLoginLogic) AdminLogin(req *types.UserLoginRequest) (resp *types.L } func (l *AdminLoginLogic) verifyCaptcha(req *types.UserLoginRequest) error { - // Get verify config from database verifyCfg, err := l.svcCtx.SystemModel.GetVerifyConfig(l.ctx) if err != nil { l.Logger.Error("[AdminLoginLogic] GetVerifyConfig error: ", logger.Field("error", err.Error())) return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "GetVerifyConfig error: %v", err.Error()) } - var config struct { + var cfg struct { CaptchaType string `json:"captcha_type"` EnableAdminLoginCaptcha bool `json:"enable_admin_login_captcha"` TurnstileSecret string `json:"turnstile_secret"` } - tool.SystemConfigSliceReflectToStruct(verifyCfg, &config) + tool.SystemConfigSliceReflectToStruct(verifyCfg, &cfg) - // Check if captcha is enabled for admin login - if !config.EnableAdminLoginCaptcha { + if !cfg.EnableAdminLoginCaptcha { return nil } - // Verify based on captcha type - if config.CaptchaType == "local" { - if req.CaptchaId == "" || req.CaptchaCode == "" { - return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "captcha required") - } - - captchaService := captcha.NewService(captcha.Config{ - Type: captcha.CaptchaTypeLocal, - RedisClient: l.svcCtx.Redis, - }) - - valid, err := captchaService.Verify(l.ctx, req.CaptchaId, req.CaptchaCode, req.IP) - if err != nil { - l.Logger.Error("[AdminLoginLogic] Verify captcha error: ", logger.Field("error", err.Error())) - return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "verify captcha error") - } - - if !valid { - return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "invalid captcha") - } - } else if config.CaptchaType == "turnstile" { - if req.CfToken == "" { - return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "captcha required") - } - - captchaService := captcha.NewService(captcha.Config{ - Type: captcha.CaptchaTypeTurnstile, - TurnstileSecret: config.TurnstileSecret, - }) - - valid, err := captchaService.Verify(l.ctx, req.CfToken, "", req.IP) - if err != nil { - l.Logger.Error("[AdminLoginLogic] Verify turnstile error: ", logger.Field("error", err.Error())) - return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "verify captcha error") - } - - if !valid { - return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "invalid captcha") - } - } - - return nil + return captcha.VerifyCaptcha(l.ctx, l.svcCtx.Redis, cfg.CaptchaType, cfg.TurnstileSecret, captcha.VerifyInput{ + CaptchaId: req.CaptchaId, + CaptchaCode: req.CaptchaCode, + CfToken: req.CfToken, + SliderToken: req.SliderToken, + IP: req.IP, + }) } diff --git a/internal/logic/auth/admin/adminResetPasswordLogic.go b/internal/logic/auth/admin/adminResetPasswordLogic.go index 544b56b..4ed13f1 100644 --- a/internal/logic/auth/admin/adminResetPasswordLogic.go +++ b/internal/logic/auth/admin/adminResetPasswordLogic.go @@ -165,65 +165,28 @@ func (l *AdminResetPasswordLogic) AdminResetPassword(req *types.ResetPasswordReq } func (l *AdminResetPasswordLogic) verifyCaptcha(req *types.ResetPasswordRequest) error { - // Get verify config from database verifyCfg, err := l.svcCtx.SystemModel.GetVerifyConfig(l.ctx) if err != nil { l.Logger.Error("[AdminResetPasswordLogic] GetVerifyConfig error: ", logger.Field("error", err.Error())) return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "GetVerifyConfig error: %v", err.Error()) } - var config struct { + var cfg struct { CaptchaType string `json:"captcha_type"` EnableAdminLoginCaptcha bool `json:"enable_admin_login_captcha"` TurnstileSecret string `json:"turnstile_secret"` } - tool.SystemConfigSliceReflectToStruct(verifyCfg, &config) + tool.SystemConfigSliceReflectToStruct(verifyCfg, &cfg) - // Check if admin login captcha is enabled (use admin login captcha for reset password) - if !config.EnableAdminLoginCaptcha { + if !cfg.EnableAdminLoginCaptcha { return nil } - // Verify based on captcha type - if config.CaptchaType == "local" { - if req.CaptchaId == "" || req.CaptchaCode == "" { - return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "captcha required") - } - - captchaService := captcha.NewService(captcha.Config{ - Type: captcha.CaptchaTypeLocal, - RedisClient: l.svcCtx.Redis, - }) - - valid, err := captchaService.Verify(l.ctx, req.CaptchaId, req.CaptchaCode, req.IP) - if err != nil { - l.Logger.Error("[AdminResetPasswordLogic] Verify captcha error: ", logger.Field("error", err.Error())) - return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "verify captcha error") - } - - if !valid { - return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "invalid captcha") - } - } else if config.CaptchaType == "turnstile" { - if req.CfToken == "" { - return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "captcha required") - } - - captchaService := captcha.NewService(captcha.Config{ - Type: captcha.CaptchaTypeTurnstile, - TurnstileSecret: config.TurnstileSecret, - }) - - valid, err := captchaService.Verify(l.ctx, req.CfToken, "", req.IP) - if err != nil { - l.Logger.Error("[AdminResetPasswordLogic] Verify turnstile error: ", logger.Field("error", err.Error())) - return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "verify captcha error") - } - - if !valid { - return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "invalid captcha") - } - } - - return nil + return captcha.VerifyCaptcha(l.ctx, l.svcCtx.Redis, cfg.CaptchaType, cfg.TurnstileSecret, captcha.VerifyInput{ + CaptchaId: req.CaptchaId, + CaptchaCode: req.CaptchaCode, + CfToken: req.CfToken, + SliderToken: req.SliderToken, + IP: req.IP, + }) } diff --git a/internal/logic/auth/admin/adminSliderVerifyCaptchaLogic.go b/internal/logic/auth/admin/adminSliderVerifyCaptchaLogic.go new file mode 100644 index 0000000..d6203ba --- /dev/null +++ b/internal/logic/auth/admin/adminSliderVerifyCaptchaLogic.go @@ -0,0 +1,57 @@ +package admin + +import ( + "context" + + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/captcha" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/tool" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" +) + +type AdminSliderVerifyCaptchaLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +// Verify slider captcha +func NewAdminSliderVerifyCaptchaLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AdminSliderVerifyCaptchaLogic { + return &AdminSliderVerifyCaptchaLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *AdminSliderVerifyCaptchaLogic) AdminSliderVerifyCaptcha(req *types.SliderVerifyCaptchaRequest) (resp *types.SliderVerifyCaptchaResponse, err error) { + // Get verify config from database + verifyCfg, err := l.svcCtx.SystemModel.GetVerifyConfig(l.ctx) + if err != nil { + l.Logger.Error("[AdminSliderVerifyCaptchaLogic] GetVerifyConfig error: ", logger.Field("error", err.Error())) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "GetVerifyConfig error: %v", err.Error()) + } + + var config struct { + CaptchaType string `json:"captcha_type"` + } + tool.SystemConfigSliceReflectToStruct(verifyCfg, &config) + + if config.CaptchaType != "slider" { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "slider captcha not enabled") + } + + sliderSvc := captcha.NewSliderService(l.svcCtx.Redis) + token, err := sliderSvc.VerifySlider(l.ctx, req.Id, req.X, req.Y, req.Trail) + if err != nil { + l.Logger.Error("[AdminSliderVerifyCaptchaLogic] VerifySlider error: ", logger.Field("error", err.Error())) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "verify slider error") + } + + return &types.SliderVerifyCaptchaResponse{ + Token: token, + }, nil +} diff --git a/internal/logic/auth/generateCaptchaLogic.go b/internal/logic/auth/generateCaptchaLogic.go index 068eb2b..615c4bc 100644 --- a/internal/logic/auth/generateCaptchaLogic.go +++ b/internal/logic/auth/generateCaptchaLogic.go @@ -64,6 +64,17 @@ func (l *GenerateCaptchaLogic) GenerateCaptcha() (resp *types.GenerateCaptchaRes } else if config.CaptchaType == "turnstile" { // For Turnstile, just return the site key resp.Id = config.TurnstileSiteKey + } else if config.CaptchaType == "slider" { + // For slider, generate background and block images + sliderSvc := captcha.NewSliderService(l.svcCtx.Redis) + id, bgImage, blockImage, err := sliderSvc.GenerateSlider(l.ctx) + if err != nil { + l.Logger.Error("[GenerateCaptchaLogic] Generate slider captcha error: ", logger.Field("error", err.Error())) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "Generate slider captcha error: %v", err.Error()) + } + resp.Id = id + resp.Image = bgImage + resp.BlockImage = blockImage } return resp, nil diff --git a/internal/logic/auth/resetPasswordLogic.go b/internal/logic/auth/resetPasswordLogic.go index 7ccc6d0..172caff 100644 --- a/internal/logic/auth/resetPasswordLogic.go +++ b/internal/logic/auth/resetPasswordLogic.go @@ -157,66 +157,28 @@ func (l *ResetPasswordLogic) ResetPassword(req *types.ResetPasswordRequest) (res } func (l *ResetPasswordLogic) verifyCaptcha(req *types.ResetPasswordRequest) error { - // Get verify config from database verifyCfg, err := l.svcCtx.SystemModel.GetVerifyConfig(l.ctx) if err != nil { l.Logger.Error("[ResetPasswordLogic] GetVerifyConfig error: ", logger.Field("error", err.Error())) return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "GetVerifyConfig error: %v", err.Error()) } - var config struct { + var cfg struct { CaptchaType string `json:"captcha_type"` EnableUserResetPasswordCaptcha bool `json:"enable_user_reset_password_captcha"` TurnstileSecret string `json:"turnstile_secret"` } - tool.SystemConfigSliceReflectToStruct(verifyCfg, &config) + tool.SystemConfigSliceReflectToStruct(verifyCfg, &cfg) - // Check if user reset password captcha is enabled - if !config.EnableUserResetPasswordCaptcha { + if !cfg.EnableUserResetPasswordCaptcha { return nil } - // Verify based on captcha type - if config.CaptchaType == "local" { - if req.CaptchaId == "" || req.CaptchaCode == "" { - return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "captcha required") - } - - captchaService := captcha.NewService(captcha.Config{ - Type: captcha.CaptchaTypeLocal, - RedisClient: l.svcCtx.Redis, - }) - - valid, err := captchaService.Verify(l.ctx, req.CaptchaId, req.CaptchaCode, req.IP) - if err != nil { - l.Logger.Error("[ResetPasswordLogic] Verify captcha error: ", logger.Field("error", err.Error())) - return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "verify captcha error") - } - - if !valid { - return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "invalid captcha") - } - } else if config.CaptchaType == "turnstile" { - if req.CfToken == "" { - return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "captcha required") - } - - captchaService := captcha.NewService(captcha.Config{ - Type: captcha.CaptchaTypeTurnstile, - TurnstileSecret: config.TurnstileSecret, - }) - - valid, err := captchaService.Verify(l.ctx, req.CfToken, "", req.IP) - if err != nil { - l.Logger.Error("[ResetPasswordLogic] Verify turnstile error: ", logger.Field("error", err.Error())) - return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "verify captcha error") - } - - if !valid { - return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "invalid captcha") - } - } - - return nil + return captcha.VerifyCaptcha(l.ctx, l.svcCtx.Redis, cfg.CaptchaType, cfg.TurnstileSecret, captcha.VerifyInput{ + CaptchaId: req.CaptchaId, + CaptchaCode: req.CaptchaCode, + CfToken: req.CfToken, + SliderToken: req.SliderToken, + IP: req.IP, + }) } - diff --git a/internal/logic/auth/sliderVerifyCaptchaLogic.go b/internal/logic/auth/sliderVerifyCaptchaLogic.go new file mode 100644 index 0000000..9659f97 --- /dev/null +++ b/internal/logic/auth/sliderVerifyCaptchaLogic.go @@ -0,0 +1,51 @@ +package auth + +import ( + "context" + + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/captcha" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/tool" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" +) + +type SliderVerifyCaptchaLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +// Verify slider captcha +func NewSliderVerifyCaptchaLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SliderVerifyCaptchaLogic { + return &SliderVerifyCaptchaLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *SliderVerifyCaptchaLogic) SliderVerifyCaptcha(req *types.SliderVerifyCaptchaRequest) (resp *types.SliderVerifyCaptchaResponse, err error) { + var config struct { + CaptchaType string `json:"captcha_type"` + } + verifyCfg, err := l.svcCtx.SystemModel.GetVerifyConfig(l.ctx) + if err != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "GetVerifyConfig error: %v", err.Error()) + } + tool.SystemConfigSliceReflectToStruct(verifyCfg, &config) + + if config.CaptchaType != string(captcha.CaptchaTypeSlider) { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "slider captcha not enabled") + } + + sliderSvc := captcha.NewSliderService(l.svcCtx.Redis) + token, err := sliderSvc.VerifySlider(l.ctx, req.Id, req.X, req.Y, req.Trail) + if err != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "slider verify failed: %v", err.Error()) + } + + return &types.SliderVerifyCaptchaResponse{Token: token}, nil +} diff --git a/internal/logic/auth/telephoneLoginLogic.go b/internal/logic/auth/telephoneLoginLogic.go index 772e006..bb538e8 100644 --- a/internal/logic/auth/telephoneLoginLogic.go +++ b/internal/logic/auth/telephoneLoginLogic.go @@ -172,65 +172,28 @@ func (l *TelephoneLoginLogic) TelephoneLogin(req *types.TelephoneLoginRequest, r } func (l *TelephoneLoginLogic) verifyCaptcha(req *types.TelephoneLoginRequest) error { - // Get verify config from database verifyCfg, err := l.svcCtx.SystemModel.GetVerifyConfig(l.ctx) if err != nil { l.Logger.Error("[TelephoneLoginLogic] GetVerifyConfig error: ", logger.Field("error", err.Error())) return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "GetVerifyConfig error: %v", err.Error()) } - var config struct { + var cfg struct { CaptchaType string `json:"captcha_type"` EnableUserLoginCaptcha bool `json:"enable_user_login_captcha"` TurnstileSecret string `json:"turnstile_secret"` } - tool.SystemConfigSliceReflectToStruct(verifyCfg, &config) + tool.SystemConfigSliceReflectToStruct(verifyCfg, &cfg) - // Check if captcha is enabled for user login - if !config.EnableUserLoginCaptcha { + if !cfg.EnableUserLoginCaptcha { return nil } - // Verify based on captcha type - if config.CaptchaType == "local" { - if req.CaptchaId == "" || req.CaptchaCode == "" { - return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "captcha required") - } - - captchaService := captcha.NewService(captcha.Config{ - Type: captcha.CaptchaTypeLocal, - RedisClient: l.svcCtx.Redis, - }) - - valid, err := captchaService.Verify(l.ctx, req.CaptchaId, req.CaptchaCode, req.IP) - if err != nil { - l.Logger.Error("[TelephoneLoginLogic] Verify captcha error: ", logger.Field("error", err.Error())) - return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "verify captcha error") - } - - if !valid { - return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "invalid captcha") - } - } else if config.CaptchaType == "turnstile" { - if req.CfToken == "" { - return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "captcha required") - } - - captchaService := captcha.NewService(captcha.Config{ - Type: captcha.CaptchaTypeTurnstile, - TurnstileSecret: config.TurnstileSecret, - }) - - valid, err := captchaService.Verify(l.ctx, req.CfToken, "", req.IP) - if err != nil { - l.Logger.Error("[TelephoneLoginLogic] Verify turnstile error: ", logger.Field("error", err.Error())) - return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "verify captcha error") - } - - if !valid { - return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "invalid captcha") - } - } - - return nil + return captcha.VerifyCaptcha(l.ctx, l.svcCtx.Redis, cfg.CaptchaType, cfg.TurnstileSecret, captcha.VerifyInput{ + CaptchaId: req.CaptchaId, + CaptchaCode: req.CaptchaCode, + CfToken: req.CfToken, + SliderToken: req.SliderToken, + IP: req.IP, + }) } diff --git a/internal/logic/auth/telephoneRegisterLogic.go b/internal/logic/auth/telephoneRegisterLogic.go new file mode 100644 index 0000000..118a6a8 --- /dev/null +++ b/internal/logic/auth/telephoneRegisterLogic.go @@ -0,0 +1,30 @@ +package auth + +import ( + "context" + + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/logger" +) + +type TelephoneRegisterLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +// User Telephone register +func NewTelephoneRegisterLogic(ctx context.Context, svcCtx *svc.ServiceContext) *TelephoneRegisterLogic { + return &TelephoneRegisterLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *TelephoneRegisterLogic) TelephoneRegister(req *types.TelephoneRegisterRequest) (resp *types.LoginResponse, err error) { + // todo: add your logic here and delete this line + + return +} diff --git a/internal/logic/auth/telephoneUserRegisterLogic.go b/internal/logic/auth/telephoneUserRegisterLogic.go index 1a5df07..faf0dff 100644 --- a/internal/logic/auth/telephoneUserRegisterLogic.go +++ b/internal/logic/auth/telephoneUserRegisterLogic.go @@ -288,66 +288,28 @@ func (l *TelephoneUserRegisterLogic) activeTrial(uid int64) (*user.Subscribe, er } func (l *TelephoneUserRegisterLogic) verifyCaptcha(req *types.TelephoneRegisterRequest) error { - // Get verify config from database verifyCfg, err := l.svcCtx.SystemModel.GetVerifyConfig(l.ctx) if err != nil { l.Logger.Error("[TelephoneUserRegisterLogic] GetVerifyConfig error: ", logger.Field("error", err.Error())) return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "GetVerifyConfig error: %v", err.Error()) } - var config struct { + var cfg struct { CaptchaType string `json:"captcha_type"` EnableUserRegisterCaptcha bool `json:"enable_user_register_captcha"` TurnstileSecret string `json:"turnstile_secret"` } - tool.SystemConfigSliceReflectToStruct(verifyCfg, &config) + tool.SystemConfigSliceReflectToStruct(verifyCfg, &cfg) - // Check if captcha is enabled for user register - if !config.EnableUserRegisterCaptcha { + if !cfg.EnableUserRegisterCaptcha { return nil } - // Verify based on captcha type - if config.CaptchaType == "local" { - if req.CaptchaId == "" || req.CaptchaCode == "" { - return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "captcha required") - } - - captchaService := captcha.NewService(captcha.Config{ - Type: captcha.CaptchaTypeLocal, - RedisClient: l.svcCtx.Redis, - }) - - valid, err := captchaService.Verify(l.ctx, req.CaptchaId, req.CaptchaCode, req.IP) - if err != nil { - l.Logger.Error("[TelephoneUserRegisterLogic] Verify captcha error: ", logger.Field("error", err.Error())) - return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "verify captcha error") - } - - if !valid { - return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "invalid captcha") - } - } else if config.CaptchaType == "turnstile" { - if req.CfToken == "" { - return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "captcha required") - } - - captchaService := captcha.NewService(captcha.Config{ - Type: captcha.CaptchaTypeTurnstile, - TurnstileSecret: config.TurnstileSecret, - }) - - valid, err := captchaService.Verify(l.ctx, req.CfToken, "", req.IP) - if err != nil { - l.Logger.Error("[TelephoneUserRegisterLogic] Verify turnstile error: ", logger.Field("error", err.Error())) - return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "verify captcha error") - } - - if !valid { - return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "invalid captcha") - } - } - - return nil + return captcha.VerifyCaptcha(l.ctx, l.svcCtx.Redis, cfg.CaptchaType, cfg.TurnstileSecret, captcha.VerifyInput{ + CaptchaId: req.CaptchaId, + CaptchaCode: req.CaptchaCode, + CfToken: req.CfToken, + SliderToken: req.SliderToken, + IP: req.IP, + }) } - diff --git a/internal/logic/auth/userLoginLogic.go b/internal/logic/auth/userLoginLogic.go index 9bd5d59..99b7b50 100644 --- a/internal/logic/auth/userLoginLogic.go +++ b/internal/logic/auth/userLoginLogic.go @@ -133,65 +133,28 @@ func (l *UserLoginLogic) UserLogin(req *types.UserLoginRequest) (resp *types.Log } func (l *UserLoginLogic) verifyCaptcha(req *types.UserLoginRequest) error { - // Get verify config from database verifyCfg, err := l.svcCtx.SystemModel.GetVerifyConfig(l.ctx) if err != nil { l.Logger.Error("[UserLoginLogic] GetVerifyConfig error: ", logger.Field("error", err.Error())) return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "GetVerifyConfig error: %v", err.Error()) } - var config struct { + var cfg struct { CaptchaType string `json:"captcha_type"` EnableUserLoginCaptcha bool `json:"enable_user_login_captcha"` TurnstileSecret string `json:"turnstile_secret"` } - tool.SystemConfigSliceReflectToStruct(verifyCfg, &config) + tool.SystemConfigSliceReflectToStruct(verifyCfg, &cfg) - // Check if captcha is enabled for user login - if !config.EnableUserLoginCaptcha { + if !cfg.EnableUserLoginCaptcha { return nil } - // Verify based on captcha type - if config.CaptchaType == "local" { - if req.CaptchaId == "" || req.CaptchaCode == "" { - return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "captcha required") - } - - captchaService := captcha.NewService(captcha.Config{ - Type: captcha.CaptchaTypeLocal, - RedisClient: l.svcCtx.Redis, - }) - - valid, err := captchaService.Verify(l.ctx, req.CaptchaId, req.CaptchaCode, req.IP) - if err != nil { - l.Logger.Error("[UserLoginLogic] Verify captcha error: ", logger.Field("error", err.Error())) - return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "verify captcha error") - } - - if !valid { - return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "invalid captcha") - } - } else if config.CaptchaType == "turnstile" { - if req.CfToken == "" { - return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "captcha required") - } - - captchaService := captcha.NewService(captcha.Config{ - Type: captcha.CaptchaTypeTurnstile, - TurnstileSecret: config.TurnstileSecret, - }) - - valid, err := captchaService.Verify(l.ctx, req.CfToken, "", req.IP) - if err != nil { - l.Logger.Error("[UserLoginLogic] Verify turnstile error: ", logger.Field("error", err.Error())) - return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "verify captcha error") - } - - if !valid { - return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "invalid captcha") - } - } - - return nil + return captcha.VerifyCaptcha(l.ctx, l.svcCtx.Redis, cfg.CaptchaType, cfg.TurnstileSecret, captcha.VerifyInput{ + CaptchaId: req.CaptchaId, + CaptchaCode: req.CaptchaCode, + CfToken: req.CfToken, + SliderToken: req.SliderToken, + IP: req.IP, + }) } diff --git a/internal/logic/auth/userRegisterLogic.go b/internal/logic/auth/userRegisterLogic.go index 423a19c..e499d00 100644 --- a/internal/logic/auth/userRegisterLogic.go +++ b/internal/logic/auth/userRegisterLogic.go @@ -332,65 +332,28 @@ func (l *UserRegisterLogic) activeTrial(uid int64) (*user.Subscribe, error) { } func (l *UserRegisterLogic) verifyCaptcha(req *types.UserRegisterRequest) error { - // Get verify config from database verifyCfg, err := l.svcCtx.SystemModel.GetVerifyConfig(l.ctx) if err != nil { l.Logger.Error("[UserRegisterLogic] GetVerifyConfig error: ", logger.Field("error", err.Error())) return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "GetVerifyConfig error: %v", err.Error()) } - var config struct { + var cfg struct { CaptchaType string `json:"captcha_type"` EnableUserRegisterCaptcha bool `json:"enable_user_register_captcha"` TurnstileSecret string `json:"turnstile_secret"` } - tool.SystemConfigSliceReflectToStruct(verifyCfg, &config) + tool.SystemConfigSliceReflectToStruct(verifyCfg, &cfg) - // Check if user register captcha is enabled - if !config.EnableUserRegisterCaptcha { + if !cfg.EnableUserRegisterCaptcha { return nil } - // Verify based on captcha type - if config.CaptchaType == "local" { - if req.CaptchaId == "" || req.CaptchaCode == "" { - return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "captcha required") - } - - captchaService := captcha.NewService(captcha.Config{ - Type: captcha.CaptchaTypeLocal, - RedisClient: l.svcCtx.Redis, - }) - - valid, err := captchaService.Verify(l.ctx, req.CaptchaId, req.CaptchaCode, req.IP) - if err != nil { - l.Logger.Error("[UserRegisterLogic] Verify captcha error: ", logger.Field("error", err.Error())) - return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "verify captcha error") - } - - if !valid { - return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "invalid captcha") - } - } else if config.CaptchaType == "turnstile" { - if req.CfToken == "" { - return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "captcha required") - } - - captchaService := captcha.NewService(captcha.Config{ - Type: captcha.CaptchaTypeTurnstile, - TurnstileSecret: config.TurnstileSecret, - }) - - valid, err := captchaService.Verify(l.ctx, req.CfToken, "", req.IP) - if err != nil { - l.Logger.Error("[UserRegisterLogic] Verify turnstile error: ", logger.Field("error", err.Error())) - return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "verify captcha error") - } - - if !valid { - return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "invalid captcha") - } - } - - return nil + return captcha.VerifyCaptcha(l.ctx, l.svcCtx.Redis, cfg.CaptchaType, cfg.TurnstileSecret, captcha.VerifyInput{ + CaptchaId: req.CaptchaId, + CaptchaCode: req.CaptchaCode, + CfToken: req.CfToken, + SliderToken: req.SliderToken, + IP: req.IP, + }) } diff --git a/internal/types/types.go b/internal/types/types.go index 88fa181..3bdcf72 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -787,9 +787,10 @@ type Follow struct { } type GenerateCaptchaResponse struct { - Id string `json:"id"` - Image string `json:"image"` - Type string `json:"type"` + Id string `json:"id"` + Image string `json:"image"` + Type string `json:"type"` + BlockImage string `json:"block_image,omitempty"` } type GetAdsDetailRequest struct { @@ -2071,6 +2072,7 @@ type ResetPasswordRequest struct { CfToken string `json:"cf_token,optional"` CaptchaId string `json:"captcha_id,optional"` CaptchaCode string `json:"captcha_code,optional"` + SliderToken string `json:"slider_token,optional"` } type ResetSortRequest struct { @@ -2294,6 +2296,17 @@ type SiteCustomDataContacts struct { Address string `json:"address"` } +type SliderVerifyCaptchaRequest struct { + Id string `json:"id" validate:"required"` + X int `json:"x" validate:"required"` + Y int `json:"y" validate:"required"` + Trail string `json:"trail"` +} + +type SliderVerifyCaptchaResponse struct { + Token string `json:"token"` +} + type SortItem struct { Id int64 `json:"id" validate:"required"` Sort int64 `json:"sort" validate:"required"` @@ -2442,6 +2455,7 @@ type TelephoneLoginRequest struct { CfToken string `json:"cf_token,optional"` CaptchaId string `json:"captcha_id,optional"` CaptchaCode string `json:"captcha_code,optional"` + SliderToken string `json:"slider_token,optional"` } type TelephoneRegisterRequest struct { @@ -2457,6 +2471,7 @@ type TelephoneRegisterRequest struct { CfToken string `json:"cf_token,optional"` CaptchaId string `json:"captcha_id,optional"` CaptchaCode string `json:"captcha_code,optional"` + SliderToken string `json:"slider_token,optional"` } type TelephoneResetPasswordRequest struct { @@ -2471,6 +2486,7 @@ type TelephoneResetPasswordRequest struct { CfToken string `json:"cf_token,optional"` CaptchaId string `json:"captcha_id,optional"` CaptchaCode string `json:"captcha_code,optional"` + SliderToken string `json:"slider_token,optional"` } type TestEmailSendRequest struct { @@ -2916,6 +2932,7 @@ type UserLoginRequest struct { CfToken string `json:"cf_token,optional"` CaptchaId string `json:"captcha_id,optional"` CaptchaCode string `json:"captcha_code,optional"` + SliderToken string `json:"slider_token,optional"` } type UserRegisterRequest struct { @@ -2930,6 +2947,7 @@ type UserRegisterRequest struct { CfToken string `json:"cf_token,optional"` CaptchaId string `json:"captcha_id,optional"` CaptchaCode string `json:"captcha_code,optional"` + SliderToken string `json:"slider_token,optional"` } type UserStatistics struct { diff --git a/pkg/captcha/service.go b/pkg/captcha/service.go index 4536227..5f8eeb0 100644 --- a/pkg/captcha/service.go +++ b/pkg/captcha/service.go @@ -11,6 +11,7 @@ type CaptchaType string const ( CaptchaTypeLocal CaptchaType = "local" CaptchaTypeTurnstile CaptchaType = "turnstile" + CaptchaTypeSlider CaptchaType = "slider" ) // Service defines the captcha service interface @@ -18,21 +19,34 @@ type Service interface { // Generate generates a new captcha // For local captcha: returns id and base64 image // For turnstile: returns empty strings + // For slider: returns id, background image, and block image (in image field as JSON) Generate(ctx context.Context) (id string, image string, err error) // Verify verifies the captcha // For local captcha: token is captcha id, code is user input // For turnstile: token is cf-turnstile-response, code is ignored + // For slider: use VerifySlider instead Verify(ctx context.Context, token string, code string, ip string) (bool, error) // GetType returns the captcha type GetType() CaptchaType } +// SliderService extends Service with slider-specific verification +type SliderService interface { + Service + // VerifySlider verifies slider position and trail, returns a one-time token on success + VerifySlider(ctx context.Context, id string, x, y int, trail string) (token string, err error) + // VerifySliderToken verifies the one-time token issued after slider verification + VerifySliderToken(ctx context.Context, token string) (bool, error) + // GenerateSlider returns id, background image base64, block image base64 + GenerateSlider(ctx context.Context) (id string, bgImage string, blockImage string, err error) +} + // Config holds the configuration for captcha service type Config struct { - Type CaptchaType - RedisClient *redis.Client + Type CaptchaType + RedisClient *redis.Client TurnstileSecret string } @@ -41,9 +55,16 @@ func NewService(config Config) Service { switch config.Type { case CaptchaTypeTurnstile: return newTurnstileService(config.TurnstileSecret) + case CaptchaTypeSlider: + return newSliderService(config.RedisClient) case CaptchaTypeLocal: fallthrough default: return newLocalService(config.RedisClient) } } + +// NewSliderService creates a slider captcha service +func NewSliderService(redisClient *redis.Client) SliderService { + return newSliderService(redisClient) +} diff --git a/pkg/captcha/slider.go b/pkg/captcha/slider.go new file mode 100644 index 0000000..7c069a2 --- /dev/null +++ b/pkg/captcha/slider.go @@ -0,0 +1,515 @@ +package captcha + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "image" + "image/color" + "image/png" + "math" + "math/rand" + "time" + + "github.com/google/uuid" + "github.com/redis/go-redis/v9" +) + +const ( + sliderBgWidth = 560 + sliderBgHeight = 280 + sliderBlockSize = 100 + sliderMinX = 140 + sliderMaxX = 420 + sliderTolerance = 6 + sliderExpiry = 5 * time.Minute + sliderTokenExpiry = 30 * time.Second +) + +type sliderShape int + +const ( + shapeSquare sliderShape = 0 + shapeCircle sliderShape = 1 + shapeDiamond sliderShape = 2 + shapeStar sliderShape = 3 + shapeTriangle sliderShape = 4 + shapeTrapezoid sliderShape = 5 +) + +type sliderService struct { + redis *redis.Client +} + +func newSliderService(redisClient *redis.Client) *sliderService { + return &sliderService{redis: redisClient} +} + +// sliderData stores the correct position and shape in Redis +type sliderData struct { + X int `json:"x"` + Y int `json:"y"` + Shape sliderShape `json:"shape"` +} + +// inMask returns true if pixel (dx,dy) within the block bounding box belongs to the shape +func inMask(dx, dy int, shape sliderShape) bool { + half := sliderBlockSize / 2 + switch shape { + case shapeCircle: + ex := dx - half + ey := dy - half + return ex*ex+ey*ey <= half*half + case shapeDiamond: + return abs(dx-half)+abs(dy-half) <= half + case shapeStar: + return inStar(dx, dy, half) + case shapeTriangle: + return inTriangle(dx, dy) + case shapeTrapezoid: + return inTrapezoid(dx, dy) + default: // shapeSquare + margin := 8 + return dx >= margin && dx < sliderBlockSize-margin && dy >= margin && dy < sliderBlockSize-margin + } +} + +func abs(v int) int { + if v < 0 { + return -v + } + return v +} + +// pointInPolygon uses ray-casting to test if (x,y) is inside the polygon defined by pts. +func pointInPolygon(x, y float64, pts [][2]float64) bool { + n := len(pts) + inside := false + j := n - 1 + for i := 0; i < n; i++ { + xi, yi := pts[i][0], pts[i][1] + xj, yj := pts[j][0], pts[j][1] + if ((yi > y) != (yj > y)) && (x < (xj-xi)*(y-yi)/(yj-yi)+xi) { + inside = !inside + } + j = i + } + return inside +} + +// inStar returns true if (dx,dy) is inside a 5-pointed star centered in the block. +func inStar(dx, dy, half int) bool { + cx, cy := float64(half), float64(half) + r1 := float64(half) * 0.92 // outer radius + r2 := float64(half) * 0.40 // inner radius + x := float64(dx) - cx + y := float64(dy) - cy + pts := make([][2]float64, 10) + for i := 0; i < 10; i++ { + angle := float64(i)*math.Pi/5 - math.Pi/2 + r := r1 + if i%2 == 1 { + r = r2 + } + pts[i] = [2]float64{r * math.Cos(angle), r * math.Sin(angle)} + } + return pointInPolygon(x, y, pts) +} + +// inTriangle returns true if (dx,dy) is inside an upward-pointing triangle. +func inTriangle(dx, dy int) bool { + margin := 5 + size := sliderBlockSize - 2*margin + half := float64(sliderBlockSize) / 2 + ax, ay := half, float64(margin) + bx, by := float64(margin), float64(margin+size) + cx, cy2 := float64(margin+size), float64(margin+size) + px, py := float64(dx), float64(dy) + d1 := (px-bx)*(ay-by) - (ax-bx)*(py-by) + d2 := (px-cx)*(by-cy2) - (bx-cx)*(py-cy2) + d3 := (px-ax)*(cy2-ay) - (cx-ax)*(py-ay) + hasNeg := (d1 < 0) || (d2 < 0) || (d3 < 0) + hasPos := (d1 > 0) || (d2 > 0) || (d3 > 0) + return !(hasNeg && hasPos) +} + +// inTrapezoid returns true if (dx,dy) is inside a trapezoid (wider at bottom). +func inTrapezoid(dx, dy int) bool { + margin := 5 + topY := float64(margin) + bottomY := float64(sliderBlockSize - margin) + totalH := bottomY - topY + half := float64(sliderBlockSize) / 2 + topHalfW := float64(sliderBlockSize) * 0.25 + bottomHalfW := float64(sliderBlockSize) * 0.45 + x, y := float64(dx), float64(dy) + if y < topY || y > bottomY { + return false + } + t := (y - topY) / totalH + hw := topHalfW + t*(bottomHalfW-topHalfW) + return x >= half-hw && x <= half+hw +} + +func (s *sliderService) GenerateSlider(ctx context.Context) (id string, bgImage string, blockImage string, err error) { + bg := generateBackground() + + r := rand.New(rand.NewSource(time.Now().UnixNano())) + x := sliderMinX + r.Intn(sliderMaxX-sliderMinX) + y := r.Intn(sliderBgHeight - sliderBlockSize) + shape := sliderShape(r.Intn(6)) + + block := cropBlockShaped(bg, x, y, shape) + cutBackgroundShaped(bg, x, y, shape) + + bgB64, err := imageToPNGBase64(bg) + if err != nil { + return "", "", "", err + } + blockB64, err := imageToPNGBase64(block) + if err != nil { + return "", "", "", err + } + + id = uuid.New().String() + data, _ := json.Marshal(sliderData{X: x, Y: y, Shape: shape}) + key := fmt.Sprintf("captcha:slider:%s", id) + if err = s.redis.Set(ctx, key, string(data), sliderExpiry).Err(); err != nil { + return "", "", "", err + } + + return id, bgB64, blockB64, nil +} + +func (s *sliderService) Generate(ctx context.Context) (id string, image string, err error) { + id, _, _, err = s.GenerateSlider(ctx) + return id, "", err +} + +// TrailPoint records a pointer position and timestamp during drag +type TrailPoint struct { + X int `json:"x"` + Y int `json:"y"` + T int64 `json:"t"` // milliseconds since drag start +} + +// validateTrail performs human-behaviour checks on the drag trail. +// +// Rules: +// 1. Trail must be provided and have >= 8 points +// 2. Total drag duration: 300ms – 15000ms +// 3. First point x <= 10 (started from left) +// 4. No single-step jump > 80px +// 5. Final x within tolerance*2 of declared x +// 6. Speed variance > 0 (not perfectly uniform / robotic) +// 7. Y-axis total deviation >= 2px (path is not a perfect horizontal line) +func validateTrail(trail []TrailPoint, declaredX int) bool { + if len(trail) < 8 { + return false + } + + duration := trail[len(trail)-1].T - trail[0].T + if duration < 300 || duration > 15000 { + return false + } + + if trail[0].X > 10 { + return false + } + + // Collect per-step speeds and check max jump + var speeds []float64 + for i := 1; i < len(trail); i++ { + dt := float64(trail[i].T - trail[i-1].T) + dx := float64(trail[i].X - trail[i-1].X) + dy := float64(trail[i].Y - trail[i-1].Y) + if abs(int(dx)) > 80 { + return false + } + if dt > 0 { + dist := math.Sqrt(dx*dx + dy*dy) + speeds = append(speeds, dist/dt) + } + } + + // Speed variance check – robot drag tends to be perfectly uniform + if len(speeds) >= 3 { + mean := 0.0 + for _, v := range speeds { + mean += v + } + mean /= float64(len(speeds)) + variance := 0.0 + for _, v := range speeds { + d := v - mean + variance += d * d + } + variance /= float64(len(speeds)) + // If variance is essentially 0, it's robotic + if variance < 1e-6 { + return false + } + } + + // Y-axis deviation: humans almost always move slightly on Y + minY := trail[0].Y + maxY := trail[0].Y + for _, p := range trail { + if p.Y < minY { + minY = p.Y + } + if p.Y > maxY { + maxY = p.Y + } + } + if maxY-minY < 2 { + return false + } + + // Final position check + lastX := trail[len(trail)-1].X + if diff := abs(lastX - declaredX); diff > sliderTolerance*2 { + return false + } + + return true +} + +func (s *sliderService) VerifySlider(ctx context.Context, id string, x, y int, trail string) (token string, err error) { + // Trail is mandatory + if trail == "" { + return "", fmt.Errorf("trail required") + } + var points []TrailPoint + if jsonErr := json.Unmarshal([]byte(trail), &points); jsonErr != nil { + return "", fmt.Errorf("invalid trail") + } + if !validateTrail(points, x) { + return "", fmt.Errorf("trail validation failed") + } + + key := fmt.Sprintf("captcha:slider:%s", id) + val, err := s.redis.Get(ctx, key).Result() + if err != nil { + return "", fmt.Errorf("captcha not found or expired") + } + + var data sliderData + if err = json.Unmarshal([]byte(val), &data); err != nil { + return "", fmt.Errorf("invalid captcha data") + } + + diffX := abs(x - data.X) + diffY := abs(y - data.Y) + if diffX > sliderTolerance || diffY > sliderTolerance { + s.redis.Del(ctx, key) + return "", fmt.Errorf("position mismatch") + } + + s.redis.Del(ctx, key) + + sliderToken := uuid.New().String() + tokenKey := fmt.Sprintf("captcha:slider:token:%s", sliderToken) + if err = s.redis.Set(ctx, tokenKey, "1", sliderTokenExpiry).Err(); err != nil { + return "", err + } + + return sliderToken, nil +} + +func (s *sliderService) VerifySliderToken(ctx context.Context, token string) (bool, error) { + if token == "" { + return false, nil + } + tokenKey := fmt.Sprintf("captcha:slider:token:%s", token) + val, err := s.redis.Get(ctx, tokenKey).Result() + if err != nil { + return false, nil + } + if val != "1" { + return false, nil + } + s.redis.Del(ctx, tokenKey) + return true, nil +} + +func (s *sliderService) Verify(ctx context.Context, token string, code string, ip string) (bool, error) { + return s.VerifySliderToken(ctx, token) +} + +func (s *sliderService) GetType() CaptchaType { + return CaptchaTypeSlider +} + +// cropBlockShaped copies pixels within the shape mask from bg into a new block image. +// Pixels outside the mask are transparent. A 2-pixel white border is drawn along the shape edge. +func cropBlockShaped(bg *image.NRGBA, x, y int, shape sliderShape) *image.NRGBA { + block := image.NewNRGBA(image.Rect(0, 0, sliderBlockSize, sliderBlockSize)) + for dy := 0; dy < sliderBlockSize; dy++ { + for dx := 0; dx < sliderBlockSize; dx++ { + if inMask(dx, dy, shape) { + block.SetNRGBA(dx, dy, bg.NRGBAAt(x+dx, y+dy)) + } + } + } + + // Draw 2-pixel bright border along shape edge + borderColor := color.NRGBA{R: 255, G: 255, B: 255, A: 230} + for dy := 0; dy < sliderBlockSize; dy++ { + for dx := 0; dx < sliderBlockSize; dx++ { + if !inMask(dx, dy, shape) { + continue + } + nearEdge := false + check: + for ddy := -2; ddy <= 2; ddy++ { + for ddx := -2; ddx <= 2; ddx++ { + if abs(ddx)+abs(ddy) > 2 { + continue + } + nx, ny := dx+ddx, dy+ddy + if nx < 0 || nx >= sliderBlockSize || ny < 0 || ny >= sliderBlockSize || !inMask(nx, ny, shape) { + nearEdge = true + break check + } + } + } + if nearEdge { + block.SetNRGBA(dx, dy, borderColor) + } + } + } + return block +} + +// cutBackgroundShaped blanks the shape area and draws a border outline +func cutBackgroundShaped(bg *image.NRGBA, x, y int, shape sliderShape) { + holeColor := color.NRGBA{R: 0, G: 0, B: 0, A: 100} + borderColor := color.NRGBA{R: 255, G: 255, B: 255, A: 220} + + // Fill hole + for dy := 0; dy < sliderBlockSize; dy++ { + for dx := 0; dx < sliderBlockSize; dx++ { + if inMask(dx, dy, shape) { + bg.SetNRGBA(x+dx, y+dy, holeColor) + } + } + } + + // Draw 2-pixel border along hole edge + for dy := 0; dy < sliderBlockSize; dy++ { + for dx := 0; dx < sliderBlockSize; dx++ { + if !inMask(dx, dy, shape) { + continue + } + nearEdge := false + check: + for ddy := -2; ddy <= 2; ddy++ { + for ddx := -2; ddx <= 2; ddx++ { + if abs(ddx)+abs(ddy) > 2 { + continue + } + nx, ny := dx+ddx, dy+ddy + if nx < 0 || nx >= sliderBlockSize || ny < 0 || ny >= sliderBlockSize || !inMask(nx, ny, shape) { + nearEdge = true + break check + } + } + } + if nearEdge { + bg.SetNRGBA(x+dx, y+dy, borderColor) + } + } + } +} + +// generateBackground creates a colorful 320x160 background image +func generateBackground() *image.NRGBA { + r := rand.New(rand.NewSource(time.Now().UnixNano())) + img := image.NewNRGBA(image.Rect(0, 0, sliderBgWidth, sliderBgHeight)) + + blockW := 60 + blockH := 60 + palette := []color.NRGBA{ + {R: 70, G: 130, B: 180, A: 255}, + {R: 60, G: 179, B: 113, A: 255}, + {R: 205, G: 92, B: 92, A: 255}, + {R: 255, G: 165, B: 0, A: 255}, + {R: 147, G: 112, B: 219, A: 255}, + {R: 64, G: 224, B: 208, A: 255}, + {R: 220, G: 120, B: 60, A: 255}, + {R: 100, G: 149, B: 237, A: 255}, + } + + for by := 0; by*blockH < sliderBgHeight; by++ { + for bx := 0; bx*blockW < sliderBgWidth; bx++ { + base := palette[r.Intn(len(palette))] + x0 := bx * blockW + y0 := by * blockH + x1 := x0 + blockW + y1 := y0 + blockH + for py := y0; py < y1 && py < sliderBgHeight; py++ { + for px := x0; px < x1 && px < sliderBgWidth; px++ { + v := int8(r.Intn(41) - 20) + img.SetNRGBA(px, py, color.NRGBA{ + R: addVariation(base.R, v), + G: addVariation(base.G, v), + B: addVariation(base.B, v), + A: 255, + }) + } + } + } + } + + // Add some random circles for visual complexity + numCircles := 6 + r.Intn(6) + for i := 0; i < numCircles; i++ { + cx := r.Intn(sliderBgWidth) + cy := r.Intn(sliderBgHeight) + radius := 18 + r.Intn(30) + circleColor := color.NRGBA{ + R: uint8(r.Intn(256)), + G: uint8(r.Intn(256)), + B: uint8(r.Intn(256)), + A: 180, + } + drawCircle(img, cx, cy, radius, circleColor) + } + + return img +} + +func addVariation(base uint8, v int8) uint8 { + result := int(base) + int(v) + if result < 0 { + return 0 + } + if result > 255 { + return 255 + } + return uint8(result) +} + +func drawCircle(img *image.NRGBA, cx, cy, radius int, c color.NRGBA) { + bounds := img.Bounds() + for y := cy - radius; y <= cy+radius; y++ { + for x := cx - radius; x <= cx+radius; x++ { + if (x-cx)*(x-cx)+(y-cy)*(y-cy) <= radius*radius { + if x >= bounds.Min.X && x < bounds.Max.X && y >= bounds.Min.Y && y < bounds.Max.Y { + img.SetNRGBA(x, y, c) + } + } + } + } +} + +func imageToPNGBase64(img image.Image) (string, error) { + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + return "", err + } + return "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()), nil +} diff --git a/pkg/captcha/verify.go b/pkg/captcha/verify.go new file mode 100644 index 0000000..7de103a --- /dev/null +++ b/pkg/captcha/verify.go @@ -0,0 +1,78 @@ +package captcha + +import ( + "context" + + "github.com/pkg/errors" + "github.com/redis/go-redis/v9" + + "github.com/perfect-panel/server/pkg/xerr" +) + +// VerifyInput holds the captcha fields from a login/register/reset request. +type VerifyInput struct { + CaptchaId string + CaptchaCode string + CfToken string + SliderToken string + IP string +} + +// VerifyCaptcha validates the captcha according to captchaType. +// Returns nil when captchaType is empty / unrecognised (i.e. captcha disabled). +func VerifyCaptcha( + ctx context.Context, + redisClient *redis.Client, + captchaType string, + turnstileSecret string, + input VerifyInput, +) error { + switch captchaType { + case string(CaptchaTypeLocal): + if input.CaptchaId == "" || input.CaptchaCode == "" { + return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "captcha required") + } + svc := NewService(Config{ + Type: CaptchaTypeLocal, + RedisClient: redisClient, + }) + valid, err := svc.Verify(ctx, input.CaptchaId, input.CaptchaCode, input.IP) + if err != nil { + return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "verify captcha error") + } + if !valid { + return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "invalid captcha") + } + + case string(CaptchaTypeTurnstile): + if input.CfToken == "" { + return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "captcha required") + } + svc := NewService(Config{ + Type: CaptchaTypeTurnstile, + TurnstileSecret: turnstileSecret, + }) + valid, err := svc.Verify(ctx, input.CfToken, "", input.IP) + if err != nil { + return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "verify captcha error") + } + if !valid { + return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "invalid captcha") + } + + case string(CaptchaTypeSlider): + if input.SliderToken == "" { + return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "slider captcha required") + } + sliderSvc := NewSliderService(redisClient) + valid, err := sliderSvc.VerifySliderToken(ctx, input.SliderToken) + if err != nil { + return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "verify captcha error") + } + if !valid { + return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "invalid slider captcha") + } + } + + return nil +}