From 821b0a6fafab63849474e4d7ce52dc3bd4dcbf09 Mon Sep 17 00:00:00 2001 From: lejianwen <84855512@qq.com> Date: Wed, 18 Dec 2024 12:43:55 +0800 Subject: [PATCH] add captcha #82 --- go.mod | 3 + http/controller/admin/login.go | 164 +++++++++++++++++++++++++++++++-- http/request/admin/login.go | 1 + http/router/admin.go | 1 + resources/i18n/en.toml | 10 ++ resources/i18n/es.toml | 10 ++ resources/i18n/ko.toml | 12 ++- resources/i18n/ru.toml | 12 ++- resources/i18n/zh_CN.toml | 12 ++- 9 files changed, 214 insertions(+), 11 deletions(-) diff --git a/go.mod b/go.mod index ad874e8..33c5718 100644 --- a/go.mod +++ b/go.mod @@ -43,6 +43,7 @@ require ( github.com/go-openapi/swag v0.19.15 // indirect github.com/go-sql-driver/mysql v1.7.0 // indirect github.com/goccy/go-json v0.10.0 // indirect + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect @@ -58,6 +59,7 @@ require ( github.com/mitchellh/mapstructure v1.4.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/mojocn/base64Captcha v1.3.6 // indirect github.com/pelletier/go-toml v1.9.4 // indirect github.com/pelletier/go-toml/v2 v2.0.6 // indirect github.com/spf13/afero v1.6.0 // indirect @@ -69,6 +71,7 @@ require ( github.com/ugorji/go/codec v1.2.9 // indirect golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect golang.org/x/crypto v0.23.0 // indirect + golang.org/x/image v0.13.0 // indirect golang.org/x/net v0.25.0 // indirect golang.org/x/sys v0.25.0 // indirect golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect diff --git a/http/controller/admin/login.go b/http/controller/admin/login.go index c00e2b6..d68191e 100644 --- a/http/controller/admin/login.go +++ b/http/controller/admin/login.go @@ -11,11 +11,126 @@ import ( "Gwen/service" "fmt" "github.com/gin-gonic/gin" + "github.com/mojocn/base64Captcha" + "sync" + "time" ) type Login struct { } +// Captcha 验证码结构 +type Captcha struct { + Id string `json:"id"` // 验证码 ID + B64 string `json:"b64"` // base64 验证码 + Code string `json:"-"` // 验证码内容 + ExpiresAt time.Time `json:"-"` // 过期时间 +} +type LoginLimiter struct { + mu sync.RWMutex + failCount map[string]int // 记录每个 IP 的失败次数 + timestamp map[string]time.Time // 记录每个 IP 的最后失败时间 + captchas map[string]Captcha // 每个 IP 的验证码 + threshold int // 失败阈值 + expiry time.Duration // 失败记录过期时间 +} + +func NewLoginLimiter(threshold int, expiry time.Duration) *LoginLimiter { + return &LoginLimiter{ + failCount: make(map[string]int), + timestamp: make(map[string]time.Time), + captchas: make(map[string]Captcha), + threshold: threshold, + expiry: expiry, + } +} + +// RecordFailure 记录登录失败 +func (l *LoginLimiter) RecordFailure(ip string) { + l.mu.Lock() + defer l.mu.Unlock() + + // 如果该 IP 的记录已经过期,重置计数 + if lastTime, exists := l.timestamp[ip]; exists && time.Since(lastTime) > l.expiry { + l.failCount[ip] = 0 + } + + // 更新失败次数和时间戳 + l.failCount[ip]++ + l.timestamp[ip] = time.Now() +} + +// NeedsCaptcha 检查是否需要验证码 +func (l *LoginLimiter) NeedsCaptcha(ip string) bool { + l.mu.RLock() + defer l.mu.RUnlock() + + // 检查记录是否存在且未过期 + if lastTime, exists := l.timestamp[ip]; exists && time.Since(lastTime) <= l.expiry { + return l.failCount[ip] >= l.threshold + } + return false +} + +// GenerateCaptcha 为指定 IP 生成验证码 +func (l *LoginLimiter) GenerateCaptcha(ip string) Captcha { + l.mu.Lock() + defer l.mu.Unlock() + + capd := base64Captcha.NewDriverString(50, 150, 5, 10, 4, "1234567890abcdefghijklmnopqrstuvwxyz", nil, nil, nil) + b64cap := base64Captcha.NewCaptcha(capd, base64Captcha.DefaultMemStore) + id, b64s, answer, err := b64cap.Generate() + if err != nil { + global.Logger.Error("Generate captcha failed: " + err.Error()) + return Captcha{} + } + // 保存验证码到对应 IP + l.captchas[ip] = Captcha{ + Id: id, + B64: b64s, + Code: answer, + ExpiresAt: time.Now().Add(5 * time.Minute), + } + return l.captchas[ip] +} + +// VerifyCaptcha 验证指定 IP 的验证码 +func (l *LoginLimiter) VerifyCaptcha(ip, code string) bool { + l.mu.RLock() + defer l.mu.RUnlock() + + // 检查验证码是否存在且未过期 + if captcha, exists := l.captchas[ip]; exists && time.Now().Before(captcha.ExpiresAt) { + return captcha.Code == code + } + return false +} + +// CleanupExpired 清理过期的记录 +func (l *LoginLimiter) CleanupExpired() { + l.mu.Lock() + defer l.mu.Unlock() + + now := time.Now() + for ip, lastTime := range l.timestamp { + if now.Sub(lastTime) > l.expiry { + delete(l.failCount, ip) + delete(l.timestamp, ip) + delete(l.captchas, ip) + } + } +} +func (l *LoginLimiter) RemoveRecord(ip string) { + l.mu.Lock() + defer l.mu.Unlock() + + delete(l.failCount, ip) + delete(l.timestamp, ip) + delete(l.captchas, ip) +} + +var loginLimiter = NewLoginLimiter(3, 5*time.Minute) + // Login 登录 // @Tags 登录 // @Summary 登录 @@ -30,22 +145,37 @@ type Login struct { func (ct *Login) Login(c *gin.Context) { f := &admin.Login{} err := c.ShouldBindJSON(f) + clientIp := c.ClientIP() if err != nil { - global.Logger.Warn(fmt.Sprintf("Login Fail: %s %s %s", "ParamsError", c.RemoteIP(), c.ClientIP())) + global.Logger.Warn(fmt.Sprintf("Login Fail: %s %s %s", "ParamsError", c.RemoteIP(), clientIp)) response.Fail(c, 101, response.TranslateMsg(c, "ParamsError")+err.Error()) return } errList := global.Validator.ValidStruct(c, f) if len(errList) > 0 { - global.Logger.Warn(fmt.Sprintf("Login Fail: %s %s %s", "ParamsError", c.RemoteIP(), c.ClientIP())) + global.Logger.Warn(fmt.Sprintf("Login Fail: %s %s %s", "ParamsError", c.RemoteIP(), clientIp)) response.Fail(c, 101, errList[0]) return } + + // 检查是否需要验证码 + if loginLimiter.NeedsCaptcha(clientIp) { + if f.Captcha == "" { + response.Fail(c, 110, response.TranslateMsg(c, "CaptchaRequired")) + return + } + if !loginLimiter.VerifyCaptcha(clientIp, f.Captcha) { + response.Fail(c, 101, response.TranslateMsg(c, "CaptchaError")) + return + } + } + u := service.AllService.UserService.InfoByUsernamePassword(f.Username, f.Password) if u.Id == 0 { - global.Logger.Warn(fmt.Sprintf("Login Fail: %s %s %s", "UsernameOrPasswordError", c.RemoteIP(), c.ClientIP())) + global.Logger.Warn(fmt.Sprintf("Login Fail: %s %s %s", "UsernameOrPasswordError", c.RemoteIP(), clientIp)) + loginLimiter.RecordFailure(clientIp) response.Fail(c, 101, response.TranslateMsg(c, "UsernameOrPasswordError")) return } @@ -54,13 +184,30 @@ func (ct *Login) Login(c *gin.Context) { UserId: u.Id, Client: model.LoginLogClientWebAdmin, Uuid: "", //must be empty - Ip: c.ClientIP(), + Ip: clientIp, Type: model.LoginLogTypeAccount, Platform: f.Platform, }) + // 成功后清除记录 + loginLimiter.RemoveRecord(clientIp) + + // 清理过期记录 + go loginLimiter.CleanupExpired() + responseLoginSuccess(c, u, ut.Token) } +func (ct *Login) Captcha(c *gin.Context) { + clientIp := c.ClientIP() + if !loginLimiter.NeedsCaptcha(clientIp) { + response.Fail(c, 101, response.TranslateMsg(c, "NoCaptchaRequired")) + return + } + captcha := loginLimiter.GenerateCaptcha(clientIp) + response.Success(c, gin.H{ + "captcha": captcha, + }) +} // Logout 登出 // @Tags 登录 @@ -90,10 +237,12 @@ func (ct *Login) Logout(c *gin.Context) { // @Failure 500 {object} response.ErrorResponse // @Router /admin/login-options [post] func (ct *Login) LoginOptions(c *gin.Context) { + ip := c.ClientIP() ops := service.AllService.OauthService.GetOauthProviders() response.Success(c, gin.H{ - "ops": ops, - "register": global.Config.App.Register, + "ops": ops, + "register": global.Config.App.Register, + "need_captcha": loginLimiter.NeedsCaptcha(ip), }) } @@ -154,11 +303,10 @@ func (ct *Login) OidcAuthQuery(c *gin.Context) { responseLoginSuccess(c, u, ut.Token) } - func responseLoginSuccess(c *gin.Context, u *model.User, token string) { lp := &adResp.LoginPayload{} lp.FromUser(u) lp.Token = token lp.RouteNames = service.AllService.UserService.RouteNames(u) response.Success(c, lp) -} \ No newline at end of file +} diff --git a/http/request/admin/login.go b/http/request/admin/login.go index 243554f..dca10ae 100644 --- a/http/request/admin/login.go +++ b/http/request/admin/login.go @@ -4,6 +4,7 @@ type Login struct { Username string `json:"username" validate:"required" label:"用户名"` Password string `json:"password,omitempty" validate:"required" label:"密码"` Platform string `json:"platform" label:"平台"` + Captcha string `json:"captcha,omitempty" label:"验证码"` } type LoginLogQuery struct { diff --git a/http/router/admin.go b/http/router/admin.go index b1aebdb..4945ebe 100644 --- a/http/router/admin.go +++ b/http/router/admin.go @@ -49,6 +49,7 @@ func Init(g *gin.Engine) { func LoginBind(rg *gin.RouterGroup) { cont := &admin.Login{} rg.POST("/login", cont.Login) + rg.GET("/captcha", cont.Captcha) rg.POST("/logout", cont.Logout) rg.GET("/login-options", cont.LoginOptions) rg.POST("/oidc/auth", cont.OidcAuth) diff --git a/resources/i18n/en.toml b/resources/i18n/en.toml index 24e339a..d6df2e9 100644 --- a/resources/i18n/en.toml +++ b/resources/i18n/en.toml @@ -123,3 +123,13 @@ other = "Share Group" description = "Register closed." one = "Register closed." other = "Register closed." + +[CaptchaRequired] +description = "Captcha required." +one = "Captcha required." +other = "Captcha required." + +[CaptchaError] +description = "Captcha error." +one = "Captcha error." +other = "Captcha error." diff --git a/resources/i18n/es.toml b/resources/i18n/es.toml index a87a733..cd63616 100644 --- a/resources/i18n/es.toml +++ b/resources/i18n/es.toml @@ -132,3 +132,13 @@ other = "Grupo compartido" description = "Register closed." one = "Registro cerrado." other = "Registro cerrado." + +[CaptchaRequired] +description = "Captcha required." +one = "Captcha requerido." +other = "Captcha requerido." + +[CaptchaError] +description = "Captcha error." +one = "Error de captcha." +other = "Error de captcha." \ No newline at end of file diff --git a/resources/i18n/ko.toml b/resources/i18n/ko.toml index 393fc49..b6eded0 100644 --- a/resources/i18n/ko.toml +++ b/resources/i18n/ko.toml @@ -125,4 +125,14 @@ other = "공유 그룹" [RegisterClosed] description = "Register closed." one = "가입이 종료되었습니다." -other = "가입이 종료되었습니다." \ No newline at end of file +other = "가입이 종료되었습니다." + +[CaptchaRequired] +description = "Captcha required." +one = "Captcha가 필요합니다." +other = "Captcha가 필요합니다." + +[CaptchaError] +description = "Captcha error." +one = "Captcha 오류." +other = "Captcha 오류." \ No newline at end of file diff --git a/resources/i18n/ru.toml b/resources/i18n/ru.toml index 885401a..9f62376 100644 --- a/resources/i18n/ru.toml +++ b/resources/i18n/ru.toml @@ -131,4 +131,14 @@ other = "Общая группа" [RegisterClosed] description = "Register closed." one = "Регистрация закрыта." -other = "Регистрация закрыта." \ No newline at end of file +other = "Регистрация закрыта." + +[CaptchaRequired] +description = "Captcha required." +one = "Требуется капча." +other = "Требуется капча." + +[CaptchaError] +description = "Captcha error." +one = "Ошибка капчи." +other = "Ошибка капчи." \ No newline at end of file diff --git a/resources/i18n/zh_CN.toml b/resources/i18n/zh_CN.toml index 1e2f0ad..4e31656 100644 --- a/resources/i18n/zh_CN.toml +++ b/resources/i18n/zh_CN.toml @@ -124,4 +124,14 @@ other = "共享组" [RegisterClosed] description = "Register closed." one = "注册已关闭。" -other = "注册已关闭。" \ No newline at end of file +other = "注册已关闭。" + +[CaptchaRequired] +description = "Captcha required." +one = "需要验证码。" +other = "需要验证码。" + +[CaptchaError] +description = "Captcha error." +one = "验证码错误。" +other = "验证码错误。" \ No newline at end of file