feat(auth): add captcha verification to user email authentication

- Add verifyCaptcha method to user login logic
- Add verifyCaptcha method to user registration logic
- Add verifyCaptcha method to password reset logic
- Support both local and Turnstile captcha verification
- Check respective configuration flags before verification
- Validate captcha code and ID for local captcha
- Validate Turnstile token for Turnstile mode
This commit is contained in:
EUForest 2026-03-09 22:55:08 +08:00
parent 9aaffec61d
commit cea3e31f3a
3 changed files with 215 additions and 3 deletions

View File

@ -8,6 +8,7 @@ import (
"github.com/perfect-panel/server/internal/model/log" "github.com/perfect-panel/server/internal/model/log"
"github.com/perfect-panel/server/internal/model/user" "github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/pkg/captcha"
"github.com/perfect-panel/server/pkg/jwt" "github.com/perfect-panel/server/pkg/jwt"
"github.com/perfect-panel/server/pkg/uuidx" "github.com/perfect-panel/server/pkg/uuidx"
@ -43,7 +44,7 @@ func (l *ResetPasswordLogic) ResetPassword(req *types.ResetPasswordRequest) (res
loginStatus := false loginStatus := false
defer func() { defer func() {
if userInfo.Id != 0 && loginStatus { if userInfo != nil && userInfo.Id != 0 && loginStatus {
loginLog := log.Login{ loginLog := log.Login{
Method: "email", Method: "email",
LoginIP: req.IP, LoginIP: req.IP,
@ -85,6 +86,11 @@ func (l *ResetPasswordLogic) ResetPassword(req *types.ResetPasswordRequest) (res
} }
} }
// Verify captcha
if err := l.verifyCaptcha(req); err != nil {
return nil, err
}
// Check user // Check user
authMethod, err := l.svcCtx.UserModel.FindUserAuthMethodByOpenID(l.ctx, "email", req.Email) authMethod, err := l.svcCtx.UserModel.FindUserAuthMethodByOpenID(l.ctx, "email", req.Email)
if err != nil { if err != nil {
@ -149,3 +155,68 @@ func (l *ResetPasswordLogic) ResetPassword(req *types.ResetPasswordRequest) (res
Token: token, Token: token,
}, nil }, nil
} }
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 {
CaptchaType string `json:"captcha_type"`
EnableUserResetPasswordCaptcha bool `json:"enable_user_reset_password_captcha"`
TurnstileSecret string `json:"turnstile_secret"`
}
tool.SystemConfigSliceReflectToStruct(verifyCfg, &config)
// Check if user reset password captcha is enabled
if !config.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
}

View File

@ -6,6 +6,7 @@ import (
"time" "time"
"github.com/perfect-panel/server/internal/model/log" "github.com/perfect-panel/server/internal/model/log"
"github.com/perfect-panel/server/pkg/captcha"
"github.com/perfect-panel/server/pkg/constant" "github.com/perfect-panel/server/pkg/constant"
"github.com/perfect-panel/server/pkg/logger" "github.com/perfect-panel/server/pkg/logger"
@ -42,7 +43,7 @@ func (l *UserLoginLogic) UserLogin(req *types.UserLoginRequest) (resp *types.Log
var userInfo *user.User var userInfo *user.User
// Record login status // Record login status
defer func(svcCtx *svc.ServiceContext) { defer func(svcCtx *svc.ServiceContext) {
if userInfo.Id != 0 { if userInfo != nil && userInfo.Id != 0 {
loginLog := log.Login{ loginLog := log.Login{
Method: "email", Method: "email",
LoginIP: req.IP, LoginIP: req.IP,
@ -66,6 +67,11 @@ func (l *UserLoginLogic) UserLogin(req *types.UserLoginRequest) (resp *types.Log
} }
}(l.svcCtx) }(l.svcCtx)
// Verify captcha
if err := l.verifyCaptcha(req); err != nil {
return nil, err
}
userInfo, err = l.svcCtx.UserModel.FindOneByEmail(l.ctx, req.Email) userInfo, err = l.svcCtx.UserModel.FindOneByEmail(l.ctx, req.Email)
if userInfo.DeletedAt.Valid { if userInfo.DeletedAt.Valid {
@ -125,3 +131,67 @@ func (l *UserLoginLogic) UserLogin(req *types.UserLoginRequest) (resp *types.Log
Token: token, Token: token,
}, nil }, nil
} }
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 {
CaptchaType string `json:"captcha_type"`
EnableUserLoginCaptcha bool `json:"enable_user_login_captcha"`
TurnstileSecret string `json:"turnstile_secret"`
}
tool.SystemConfigSliceReflectToStruct(verifyCfg, &config)
// Check if captcha is enabled for user login
if !config.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
}

View File

@ -13,6 +13,7 @@ import (
"github.com/perfect-panel/server/internal/model/user" "github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/internal/svc" "github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types" "github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/captcha"
"github.com/perfect-panel/server/pkg/constant" "github.com/perfect-panel/server/pkg/constant"
"github.com/perfect-panel/server/pkg/jwt" "github.com/perfect-panel/server/pkg/jwt"
"github.com/perfect-panel/server/pkg/logger" "github.com/perfect-panel/server/pkg/logger"
@ -80,6 +81,12 @@ func (l *UserRegisterLogic) UserRegister(req *types.UserRegisterRequest) (resp *
return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code error") return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code error")
} }
} }
// Verify captcha
if err := l.verifyCaptcha(req); err != nil {
return nil, err
}
// Check if the user exists // Check if the user exists
u, err := l.svcCtx.UserModel.FindOneByEmail(l.ctx, req.Email) u, err := l.svcCtx.UserModel.FindOneByEmail(l.ctx, req.Email)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
@ -250,7 +257,7 @@ func (l *UserRegisterLogic) UserRegister(req *types.UserRegisterRequest) (resp *
} }
loginStatus := true loginStatus := true
defer func() { defer func() {
if token != "" && userInfo.Id != 0 { if token != "" && userInfo != nil && userInfo.Id != 0 {
loginLog := log.Login{ loginLog := log.Login{
Method: "email", Method: "email",
LoginIP: req.IP, LoginIP: req.IP,
@ -323,3 +330,67 @@ func (l *UserRegisterLogic) activeTrial(uid int64) (*user.Subscribe, error) {
} }
return userSub, nil return userSub, nil
} }
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 {
CaptchaType string `json:"captcha_type"`
EnableUserRegisterCaptcha bool `json:"enable_user_register_captcha"`
TurnstileSecret string `json:"turnstile_secret"`
}
tool.SystemConfigSliceReflectToStruct(verifyCfg, &config)
// Check if user register captcha is enabled
if !config.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
}