feat: Add slider verification code

This commit is contained in:
EUForest 2026-03-23 02:42:12 +08:00
parent bc721b0ba6
commit 7fe7243c24
21 changed files with 987 additions and 349 deletions

View File

@ -20,6 +20,7 @@ type (
CfToken string `json:"cf_token,optional"` CfToken string `json:"cf_token,optional"`
CaptchaId string `json:"captcha_id,optional"` CaptchaId string `json:"captcha_id,optional"`
CaptchaCode string `json:"captcha_code,optional"` CaptchaCode string `json:"captcha_code,optional"`
SliderToken string `json:"slider_token,optional"`
} }
// Check user is exist request // Check user is exist request
CheckUserRequest { CheckUserRequest {
@ -42,6 +43,7 @@ type (
CfToken string `json:"cf_token,optional"` CfToken string `json:"cf_token,optional"`
CaptchaId string `json:"captcha_id,optional"` CaptchaId string `json:"captcha_id,optional"`
CaptchaCode string `json:"captcha_code,optional"` CaptchaCode string `json:"captcha_code,optional"`
SliderToken string `json:"slider_token,optional"`
} }
// User reset password request // User reset password request
ResetPasswordRequest { ResetPasswordRequest {
@ -55,6 +57,7 @@ type (
CfToken string `json:"cf_token,optional"` CfToken string `json:"cf_token,optional"`
CaptchaId string `json:"captcha_id,optional"` CaptchaId string `json:"captcha_id,optional"`
CaptchaCode string `json:"captcha_code,optional"` CaptchaCode string `json:"captcha_code,optional"`
SliderToken string `json:"slider_token,optional"`
} }
LoginResponse { LoginResponse {
Token string `json:"token"` Token string `json:"token"`
@ -83,6 +86,7 @@ type (
CfToken string `json:"cf_token,optional"` CfToken string `json:"cf_token,optional"`
CaptchaId string `json:"captcha_id,optional"` CaptchaId string `json:"captcha_id,optional"`
CaptchaCode string `json:"captcha_code,optional"` CaptchaCode string `json:"captcha_code,optional"`
SliderToken string `json:"slider_token,optional"`
} }
// Check user is exist request // Check user is exist request
TelephoneCheckUserRequest { TelephoneCheckUserRequest {
@ -107,6 +111,7 @@ type (
CfToken string `json:"cf_token,optional"` CfToken string `json:"cf_token,optional"`
CaptchaId string `json:"captcha_id,optional"` CaptchaId string `json:"captcha_id,optional"`
CaptchaCode string `json:"captcha_code,optional"` CaptchaCode string `json:"captcha_code,optional"`
SliderToken string `json:"slider_token,optional"`
} }
// User login response // User login response
TelephoneResetPasswordRequest { TelephoneResetPasswordRequest {
@ -121,6 +126,7 @@ type (
CfToken string `json:"cf_token,optional"` CfToken string `json:"cf_token,optional"`
CaptchaId string `json:"captcha_id,optional"` CaptchaId string `json:"captcha_id,optional"`
CaptchaCode string `json:"captcha_code,optional"` CaptchaCode string `json:"captcha_code,optional"`
SliderToken string `json:"slider_token,optional"`
} }
AppleLoginCallbackRequest { AppleLoginCallbackRequest {
Code string `form:"code"` Code string `form:"code"`
@ -142,6 +148,16 @@ type (
Id string `json:"id"` Id string `json:"id"`
Image string `json:"image"` Image string `json:"image"`
Type string `json:"type"` 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) get /check/telephone (TelephoneCheckUserRequest) returns (TelephoneCheckUserResponse)
@doc "User Telephone register" @doc "User Telephone register"
@handler TelephoneUserRegister @handler TelephoneRegister
post /register/telephone (TelephoneRegisterRequest) returns (LoginResponse) post /register/telephone (TelephoneRegisterRequest) returns (LoginResponse)
@doc "Reset password" @doc "Reset password by telephone"
@handler TelephoneResetPassword @handler TelephoneResetPassword
post /reset/telephone (TelephoneResetPasswordRequest) returns (LoginResponse) post /reset/telephone (TelephoneResetPasswordRequest) returns (LoginResponse)
@doc "Device Login"
@handler DeviceLogin
post /login/device (DeviceLoginRequest) returns (LoginResponse)
@doc "Generate captcha" @doc "Generate captcha"
@handler GenerateCaptcha @handler GenerateCaptcha
post /captcha/generate returns (GenerateCaptchaResponse) post /captcha/generate returns (GenerateCaptchaResponse)
@doc "Device Login" @doc "Verify slider captcha"
@handler DeviceLogin @handler SliderVerifyCaptcha
post /login/device (DeviceLoginRequest) returns (LoginResponse) post /captcha/slider/verify (SliderVerifyCaptchaRequest) returns (SliderVerifyCaptchaResponse)
} }
@server ( @server (
@ -209,6 +229,10 @@ service ppanel {
@doc "Generate captcha" @doc "Generate captcha"
@handler AdminGenerateCaptcha @handler AdminGenerateCaptcha
post /captcha/generate returns (GenerateCaptchaResponse) post /captcha/generate returns (GenerateCaptchaResponse)
@doc "Verify slider captcha"
@handler AdminSliderVerifyCaptcha
post /captcha/slider/verify (SliderVerifyCaptchaRequest) returns (SliderVerifyCaptchaResponse)
} }
@server ( @server (
@ -228,4 +252,3 @@ service ppanel {
@handler AppleLoginCallback @handler AppleLoginCallback
post /callback/apple (AppleLoginCallbackRequest) post /callback/apple (AppleLoginCallbackRequest)
} }

View File

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

View File

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

View File

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

View File

@ -674,6 +674,9 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
// Generate captcha // Generate captcha
authGroupRouter.POST("/captcha/generate", auth.GenerateCaptchaHandler(serverCtx)) authGroupRouter.POST("/captcha/generate", auth.GenerateCaptchaHandler(serverCtx))
// Verify slider captcha
authGroupRouter.POST("/captcha/slider/verify", auth.SliderVerifyCaptchaHandler(serverCtx))
// Check user is exist // Check user is exist
authGroupRouter.GET("/check", auth.CheckUserHandler(serverCtx)) authGroupRouter.GET("/check", auth.CheckUserHandler(serverCtx))
@ -693,12 +696,12 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
authGroupRouter.POST("/register", auth.UserRegisterHandler(serverCtx)) authGroupRouter.POST("/register", auth.UserRegisterHandler(serverCtx))
// User Telephone register // User Telephone register
authGroupRouter.POST("/register/telephone", auth.TelephoneUserRegisterHandler(serverCtx)) authGroupRouter.POST("/register/telephone", auth.TelephoneRegisterHandler(serverCtx))
// Reset password // Reset password
authGroupRouter.POST("/reset", auth.ResetPasswordHandler(serverCtx)) authGroupRouter.POST("/reset", auth.ResetPasswordHandler(serverCtx))
// Reset password // Reset password by telephone
authGroupRouter.POST("/reset/telephone", auth.TelephoneResetPasswordHandler(serverCtx)) authGroupRouter.POST("/reset/telephone", auth.TelephoneResetPasswordHandler(serverCtx))
} }
@ -709,6 +712,9 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
// Generate captcha // Generate captcha
authAdminGroupRouter.POST("/captcha/generate", authAdmin.AdminGenerateCaptchaHandler(serverCtx)) authAdminGroupRouter.POST("/captcha/generate", authAdmin.AdminGenerateCaptchaHandler(serverCtx))
// Verify slider captcha
authAdminGroupRouter.POST("/captcha/slider/verify", authAdmin.AdminSliderVerifyCaptchaHandler(serverCtx))
// Admin login // Admin login
authAdminGroupRouter.POST("/login", authAdmin.AdminLoginHandler(serverCtx)) authAdminGroupRouter.POST("/login", authAdmin.AdminLoginHandler(serverCtx))
@ -1008,10 +1014,10 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
serverGroupRouter.GET("/user", server.GetServerUserListHandler(serverCtx)) serverGroupRouter.GET("/user", server.GetServerUserListHandler(serverCtx))
} }
serverV2GroupRouter := router.Group("/v2/server") serverGroupRouterV2 := router.Group("/v2/server")
{ {
// Get Server Protocol Config // Get Server Protocol Config
serverV2GroupRouter.GET("/:server_id", server.QueryServerProtocolConfigHandler(serverCtx)) serverGroupRouterV2.GET("/:server_id", server.QueryServerProtocolConfigHandler(serverCtx))
} }
} }

View File

@ -64,6 +64,17 @@ func (l *AdminGenerateCaptchaLogic) AdminGenerateCaptcha() (resp *types.Generate
} else if config.CaptchaType == "turnstile" { } else if config.CaptchaType == "turnstile" {
// For Turnstile, just return the site key // For Turnstile, just return the site key
resp.Id = config.TurnstileSiteKey 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 return resp, nil

View File

@ -137,65 +137,28 @@ func (l *AdminLoginLogic) AdminLogin(req *types.UserLoginRequest) (resp *types.L
} }
func (l *AdminLoginLogic) verifyCaptcha(req *types.UserLoginRequest) error { func (l *AdminLoginLogic) verifyCaptcha(req *types.UserLoginRequest) error {
// Get verify config from database
verifyCfg, err := l.svcCtx.SystemModel.GetVerifyConfig(l.ctx) verifyCfg, err := l.svcCtx.SystemModel.GetVerifyConfig(l.ctx)
if err != nil { if err != nil {
l.Logger.Error("[AdminLoginLogic] GetVerifyConfig error: ", logger.Field("error", err.Error())) l.Logger.Error("[AdminLoginLogic] GetVerifyConfig error: ", logger.Field("error", err.Error()))
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "GetVerifyConfig error: %v", 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"` CaptchaType string `json:"captcha_type"`
EnableAdminLoginCaptcha bool `json:"enable_admin_login_captcha"` EnableAdminLoginCaptcha bool `json:"enable_admin_login_captcha"`
TurnstileSecret string `json:"turnstile_secret"` TurnstileSecret string `json:"turnstile_secret"`
} }
tool.SystemConfigSliceReflectToStruct(verifyCfg, &config) tool.SystemConfigSliceReflectToStruct(verifyCfg, &cfg)
// Check if captcha is enabled for admin login if !cfg.EnableAdminLoginCaptcha {
if !config.EnableAdminLoginCaptcha {
return nil return nil
} }
// Verify based on captcha type return captcha.VerifyCaptcha(l.ctx, l.svcCtx.Redis, cfg.CaptchaType, cfg.TurnstileSecret, captcha.VerifyInput{
if config.CaptchaType == "local" { CaptchaId: req.CaptchaId,
if req.CaptchaId == "" || req.CaptchaCode == "" { CaptchaCode: req.CaptchaCode,
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "captcha required") CfToken: req.CfToken,
} SliderToken: req.SliderToken,
IP: req.IP,
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
} }

View File

@ -165,65 +165,28 @@ func (l *AdminResetPasswordLogic) AdminResetPassword(req *types.ResetPasswordReq
} }
func (l *AdminResetPasswordLogic) verifyCaptcha(req *types.ResetPasswordRequest) error { func (l *AdminResetPasswordLogic) verifyCaptcha(req *types.ResetPasswordRequest) error {
// Get verify config from database
verifyCfg, err := l.svcCtx.SystemModel.GetVerifyConfig(l.ctx) verifyCfg, err := l.svcCtx.SystemModel.GetVerifyConfig(l.ctx)
if err != nil { if err != nil {
l.Logger.Error("[AdminResetPasswordLogic] GetVerifyConfig error: ", logger.Field("error", err.Error())) l.Logger.Error("[AdminResetPasswordLogic] GetVerifyConfig error: ", logger.Field("error", err.Error()))
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "GetVerifyConfig error: %v", 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"` CaptchaType string `json:"captcha_type"`
EnableAdminLoginCaptcha bool `json:"enable_admin_login_captcha"` EnableAdminLoginCaptcha bool `json:"enable_admin_login_captcha"`
TurnstileSecret string `json:"turnstile_secret"` 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 !cfg.EnableAdminLoginCaptcha {
if !config.EnableAdminLoginCaptcha {
return nil return nil
} }
// Verify based on captcha type return captcha.VerifyCaptcha(l.ctx, l.svcCtx.Redis, cfg.CaptchaType, cfg.TurnstileSecret, captcha.VerifyInput{
if config.CaptchaType == "local" { CaptchaId: req.CaptchaId,
if req.CaptchaId == "" || req.CaptchaCode == "" { CaptchaCode: req.CaptchaCode,
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "captcha required") CfToken: req.CfToken,
} SliderToken: req.SliderToken,
IP: req.IP,
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
} }

View File

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

View File

@ -64,6 +64,17 @@ func (l *GenerateCaptchaLogic) GenerateCaptcha() (resp *types.GenerateCaptchaRes
} else if config.CaptchaType == "turnstile" { } else if config.CaptchaType == "turnstile" {
// For Turnstile, just return the site key // For Turnstile, just return the site key
resp.Id = config.TurnstileSiteKey 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 return resp, nil

View File

@ -157,66 +157,28 @@ func (l *ResetPasswordLogic) ResetPassword(req *types.ResetPasswordRequest) (res
} }
func (l *ResetPasswordLogic) verifyCaptcha(req *types.ResetPasswordRequest) error { func (l *ResetPasswordLogic) verifyCaptcha(req *types.ResetPasswordRequest) error {
// Get verify config from database
verifyCfg, err := l.svcCtx.SystemModel.GetVerifyConfig(l.ctx) verifyCfg, err := l.svcCtx.SystemModel.GetVerifyConfig(l.ctx)
if err != nil { if err != nil {
l.Logger.Error("[ResetPasswordLogic] GetVerifyConfig error: ", logger.Field("error", err.Error())) l.Logger.Error("[ResetPasswordLogic] GetVerifyConfig error: ", logger.Field("error", err.Error()))
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "GetVerifyConfig error: %v", 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"` CaptchaType string `json:"captcha_type"`
EnableUserResetPasswordCaptcha bool `json:"enable_user_reset_password_captcha"` EnableUserResetPasswordCaptcha bool `json:"enable_user_reset_password_captcha"`
TurnstileSecret string `json:"turnstile_secret"` TurnstileSecret string `json:"turnstile_secret"`
} }
tool.SystemConfigSliceReflectToStruct(verifyCfg, &config) tool.SystemConfigSliceReflectToStruct(verifyCfg, &cfg)
// Check if user reset password captcha is enabled if !cfg.EnableUserResetPasswordCaptcha {
if !config.EnableUserResetPasswordCaptcha {
return nil return nil
} }
// Verify based on captcha type return captcha.VerifyCaptcha(l.ctx, l.svcCtx.Redis, cfg.CaptchaType, cfg.TurnstileSecret, captcha.VerifyInput{
if config.CaptchaType == "local" { CaptchaId: req.CaptchaId,
if req.CaptchaId == "" || req.CaptchaCode == "" { CaptchaCode: req.CaptchaCode,
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "captcha required") CfToken: req.CfToken,
} SliderToken: req.SliderToken,
IP: req.IP,
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
}

View File

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

View File

@ -172,65 +172,28 @@ func (l *TelephoneLoginLogic) TelephoneLogin(req *types.TelephoneLoginRequest, r
} }
func (l *TelephoneLoginLogic) verifyCaptcha(req *types.TelephoneLoginRequest) error { func (l *TelephoneLoginLogic) verifyCaptcha(req *types.TelephoneLoginRequest) error {
// Get verify config from database
verifyCfg, err := l.svcCtx.SystemModel.GetVerifyConfig(l.ctx) verifyCfg, err := l.svcCtx.SystemModel.GetVerifyConfig(l.ctx)
if err != nil { if err != nil {
l.Logger.Error("[TelephoneLoginLogic] GetVerifyConfig error: ", logger.Field("error", err.Error())) l.Logger.Error("[TelephoneLoginLogic] GetVerifyConfig error: ", logger.Field("error", err.Error()))
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "GetVerifyConfig error: %v", 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"` CaptchaType string `json:"captcha_type"`
EnableUserLoginCaptcha bool `json:"enable_user_login_captcha"` EnableUserLoginCaptcha bool `json:"enable_user_login_captcha"`
TurnstileSecret string `json:"turnstile_secret"` TurnstileSecret string `json:"turnstile_secret"`
} }
tool.SystemConfigSliceReflectToStruct(verifyCfg, &config) tool.SystemConfigSliceReflectToStruct(verifyCfg, &cfg)
// Check if captcha is enabled for user login if !cfg.EnableUserLoginCaptcha {
if !config.EnableUserLoginCaptcha {
return nil return nil
} }
// Verify based on captcha type return captcha.VerifyCaptcha(l.ctx, l.svcCtx.Redis, cfg.CaptchaType, cfg.TurnstileSecret, captcha.VerifyInput{
if config.CaptchaType == "local" { CaptchaId: req.CaptchaId,
if req.CaptchaId == "" || req.CaptchaCode == "" { CaptchaCode: req.CaptchaCode,
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "captcha required") CfToken: req.CfToken,
} SliderToken: req.SliderToken,
IP: req.IP,
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
} }

View File

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

View File

@ -288,66 +288,28 @@ func (l *TelephoneUserRegisterLogic) activeTrial(uid int64) (*user.Subscribe, er
} }
func (l *TelephoneUserRegisterLogic) verifyCaptcha(req *types.TelephoneRegisterRequest) error { func (l *TelephoneUserRegisterLogic) verifyCaptcha(req *types.TelephoneRegisterRequest) error {
// Get verify config from database
verifyCfg, err := l.svcCtx.SystemModel.GetVerifyConfig(l.ctx) verifyCfg, err := l.svcCtx.SystemModel.GetVerifyConfig(l.ctx)
if err != nil { if err != nil {
l.Logger.Error("[TelephoneUserRegisterLogic] GetVerifyConfig error: ", logger.Field("error", err.Error())) l.Logger.Error("[TelephoneUserRegisterLogic] GetVerifyConfig error: ", logger.Field("error", err.Error()))
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "GetVerifyConfig error: %v", 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"` CaptchaType string `json:"captcha_type"`
EnableUserRegisterCaptcha bool `json:"enable_user_register_captcha"` EnableUserRegisterCaptcha bool `json:"enable_user_register_captcha"`
TurnstileSecret string `json:"turnstile_secret"` TurnstileSecret string `json:"turnstile_secret"`
} }
tool.SystemConfigSliceReflectToStruct(verifyCfg, &config) tool.SystemConfigSliceReflectToStruct(verifyCfg, &cfg)
// Check if captcha is enabled for user register if !cfg.EnableUserRegisterCaptcha {
if !config.EnableUserRegisterCaptcha {
return nil return nil
} }
// Verify based on captcha type return captcha.VerifyCaptcha(l.ctx, l.svcCtx.Redis, cfg.CaptchaType, cfg.TurnstileSecret, captcha.VerifyInput{
if config.CaptchaType == "local" { CaptchaId: req.CaptchaId,
if req.CaptchaId == "" || req.CaptchaCode == "" { CaptchaCode: req.CaptchaCode,
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "captcha required") CfToken: req.CfToken,
} SliderToken: req.SliderToken,
IP: req.IP,
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
}

View File

@ -133,65 +133,28 @@ func (l *UserLoginLogic) UserLogin(req *types.UserLoginRequest) (resp *types.Log
} }
func (l *UserLoginLogic) verifyCaptcha(req *types.UserLoginRequest) error { func (l *UserLoginLogic) verifyCaptcha(req *types.UserLoginRequest) error {
// Get verify config from database
verifyCfg, err := l.svcCtx.SystemModel.GetVerifyConfig(l.ctx) verifyCfg, err := l.svcCtx.SystemModel.GetVerifyConfig(l.ctx)
if err != nil { if err != nil {
l.Logger.Error("[UserLoginLogic] GetVerifyConfig error: ", logger.Field("error", err.Error())) l.Logger.Error("[UserLoginLogic] GetVerifyConfig error: ", logger.Field("error", err.Error()))
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "GetVerifyConfig error: %v", 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"` CaptchaType string `json:"captcha_type"`
EnableUserLoginCaptcha bool `json:"enable_user_login_captcha"` EnableUserLoginCaptcha bool `json:"enable_user_login_captcha"`
TurnstileSecret string `json:"turnstile_secret"` TurnstileSecret string `json:"turnstile_secret"`
} }
tool.SystemConfigSliceReflectToStruct(verifyCfg, &config) tool.SystemConfigSliceReflectToStruct(verifyCfg, &cfg)
// Check if captcha is enabled for user login if !cfg.EnableUserLoginCaptcha {
if !config.EnableUserLoginCaptcha {
return nil return nil
} }
// Verify based on captcha type return captcha.VerifyCaptcha(l.ctx, l.svcCtx.Redis, cfg.CaptchaType, cfg.TurnstileSecret, captcha.VerifyInput{
if config.CaptchaType == "local" { CaptchaId: req.CaptchaId,
if req.CaptchaId == "" || req.CaptchaCode == "" { CaptchaCode: req.CaptchaCode,
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "captcha required") CfToken: req.CfToken,
} SliderToken: req.SliderToken,
IP: req.IP,
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
} }

View File

@ -332,65 +332,28 @@ func (l *UserRegisterLogic) activeTrial(uid int64) (*user.Subscribe, error) {
} }
func (l *UserRegisterLogic) verifyCaptcha(req *types.UserRegisterRequest) error { func (l *UserRegisterLogic) verifyCaptcha(req *types.UserRegisterRequest) error {
// Get verify config from database
verifyCfg, err := l.svcCtx.SystemModel.GetVerifyConfig(l.ctx) verifyCfg, err := l.svcCtx.SystemModel.GetVerifyConfig(l.ctx)
if err != nil { if err != nil {
l.Logger.Error("[UserRegisterLogic] GetVerifyConfig error: ", logger.Field("error", err.Error())) l.Logger.Error("[UserRegisterLogic] GetVerifyConfig error: ", logger.Field("error", err.Error()))
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "GetVerifyConfig error: %v", 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"` CaptchaType string `json:"captcha_type"`
EnableUserRegisterCaptcha bool `json:"enable_user_register_captcha"` EnableUserRegisterCaptcha bool `json:"enable_user_register_captcha"`
TurnstileSecret string `json:"turnstile_secret"` TurnstileSecret string `json:"turnstile_secret"`
} }
tool.SystemConfigSliceReflectToStruct(verifyCfg, &config) tool.SystemConfigSliceReflectToStruct(verifyCfg, &cfg)
// Check if user register captcha is enabled if !cfg.EnableUserRegisterCaptcha {
if !config.EnableUserRegisterCaptcha {
return nil return nil
} }
// Verify based on captcha type return captcha.VerifyCaptcha(l.ctx, l.svcCtx.Redis, cfg.CaptchaType, cfg.TurnstileSecret, captcha.VerifyInput{
if config.CaptchaType == "local" { CaptchaId: req.CaptchaId,
if req.CaptchaId == "" || req.CaptchaCode == "" { CaptchaCode: req.CaptchaCode,
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "captcha required") CfToken: req.CfToken,
} SliderToken: req.SliderToken,
IP: req.IP,
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
} }

View File

@ -790,6 +790,7 @@ type GenerateCaptchaResponse struct {
Id string `json:"id"` Id string `json:"id"`
Image string `json:"image"` Image string `json:"image"`
Type string `json:"type"` Type string `json:"type"`
BlockImage string `json:"block_image,omitempty"`
} }
type GetAdsDetailRequest struct { type GetAdsDetailRequest struct {
@ -2071,6 +2072,7 @@ type ResetPasswordRequest struct {
CfToken string `json:"cf_token,optional"` CfToken string `json:"cf_token,optional"`
CaptchaId string `json:"captcha_id,optional"` CaptchaId string `json:"captcha_id,optional"`
CaptchaCode string `json:"captcha_code,optional"` CaptchaCode string `json:"captcha_code,optional"`
SliderToken string `json:"slider_token,optional"`
} }
type ResetSortRequest struct { type ResetSortRequest struct {
@ -2294,6 +2296,17 @@ type SiteCustomDataContacts struct {
Address string `json:"address"` 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 { type SortItem struct {
Id int64 `json:"id" validate:"required"` Id int64 `json:"id" validate:"required"`
Sort int64 `json:"sort" validate:"required"` Sort int64 `json:"sort" validate:"required"`
@ -2442,6 +2455,7 @@ type TelephoneLoginRequest struct {
CfToken string `json:"cf_token,optional"` CfToken string `json:"cf_token,optional"`
CaptchaId string `json:"captcha_id,optional"` CaptchaId string `json:"captcha_id,optional"`
CaptchaCode string `json:"captcha_code,optional"` CaptchaCode string `json:"captcha_code,optional"`
SliderToken string `json:"slider_token,optional"`
} }
type TelephoneRegisterRequest struct { type TelephoneRegisterRequest struct {
@ -2457,6 +2471,7 @@ type TelephoneRegisterRequest struct {
CfToken string `json:"cf_token,optional"` CfToken string `json:"cf_token,optional"`
CaptchaId string `json:"captcha_id,optional"` CaptchaId string `json:"captcha_id,optional"`
CaptchaCode string `json:"captcha_code,optional"` CaptchaCode string `json:"captcha_code,optional"`
SliderToken string `json:"slider_token,optional"`
} }
type TelephoneResetPasswordRequest struct { type TelephoneResetPasswordRequest struct {
@ -2471,6 +2486,7 @@ type TelephoneResetPasswordRequest struct {
CfToken string `json:"cf_token,optional"` CfToken string `json:"cf_token,optional"`
CaptchaId string `json:"captcha_id,optional"` CaptchaId string `json:"captcha_id,optional"`
CaptchaCode string `json:"captcha_code,optional"` CaptchaCode string `json:"captcha_code,optional"`
SliderToken string `json:"slider_token,optional"`
} }
type TestEmailSendRequest struct { type TestEmailSendRequest struct {
@ -2916,6 +2932,7 @@ type UserLoginRequest struct {
CfToken string `json:"cf_token,optional"` CfToken string `json:"cf_token,optional"`
CaptchaId string `json:"captcha_id,optional"` CaptchaId string `json:"captcha_id,optional"`
CaptchaCode string `json:"captcha_code,optional"` CaptchaCode string `json:"captcha_code,optional"`
SliderToken string `json:"slider_token,optional"`
} }
type UserRegisterRequest struct { type UserRegisterRequest struct {
@ -2930,6 +2947,7 @@ type UserRegisterRequest struct {
CfToken string `json:"cf_token,optional"` CfToken string `json:"cf_token,optional"`
CaptchaId string `json:"captcha_id,optional"` CaptchaId string `json:"captcha_id,optional"`
CaptchaCode string `json:"captcha_code,optional"` CaptchaCode string `json:"captcha_code,optional"`
SliderToken string `json:"slider_token,optional"`
} }
type UserStatistics struct { type UserStatistics struct {

View File

@ -11,6 +11,7 @@ type CaptchaType string
const ( const (
CaptchaTypeLocal CaptchaType = "local" CaptchaTypeLocal CaptchaType = "local"
CaptchaTypeTurnstile CaptchaType = "turnstile" CaptchaTypeTurnstile CaptchaType = "turnstile"
CaptchaTypeSlider CaptchaType = "slider"
) )
// Service defines the captcha service interface // Service defines the captcha service interface
@ -18,17 +19,30 @@ type Service interface {
// Generate generates a new captcha // Generate generates a new captcha
// For local captcha: returns id and base64 image // For local captcha: returns id and base64 image
// For turnstile: returns empty strings // 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) Generate(ctx context.Context) (id string, image string, err error)
// Verify verifies the captcha // Verify verifies the captcha
// For local captcha: token is captcha id, code is user input // For local captcha: token is captcha id, code is user input
// For turnstile: token is cf-turnstile-response, code is ignored // 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) Verify(ctx context.Context, token string, code string, ip string) (bool, error)
// GetType returns the captcha type // GetType returns the captcha type
GetType() CaptchaType 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 // Config holds the configuration for captcha service
type Config struct { type Config struct {
Type CaptchaType Type CaptchaType
@ -41,9 +55,16 @@ func NewService(config Config) Service {
switch config.Type { switch config.Type {
case CaptchaTypeTurnstile: case CaptchaTypeTurnstile:
return newTurnstileService(config.TurnstileSecret) return newTurnstileService(config.TurnstileSecret)
case CaptchaTypeSlider:
return newSliderService(config.RedisClient)
case CaptchaTypeLocal: case CaptchaTypeLocal:
fallthrough fallthrough
default: default:
return newLocalService(config.RedisClient) return newLocalService(config.RedisClient)
} }
} }
// NewSliderService creates a slider captcha service
func NewSliderService(redisClient *redis.Client) SliderService {
return newSliderService(redisClient)
}

515
pkg/captcha/slider.go Normal file
View File

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

78
pkg/captcha/verify.go Normal file
View File

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