lejianwen 1 год назад
Родитель
Сommit
bf8eadfbaa

+ 3 - 0
go.mod

@@ -43,6 +43,7 @@ require (
43 43
 	github.com/go-openapi/swag v0.19.15 // indirect
44 44
 	github.com/go-sql-driver/mysql v1.7.0 // indirect
45 45
 	github.com/goccy/go-json v0.10.0 // indirect
46
+	github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
46 47
 	github.com/hashicorp/hcl v1.0.0 // indirect
47 48
 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
48 49
 	github.com/jinzhu/inflection v1.0.0 // indirect
@@ -58,6 +59,7 @@ require (
58 59
 	github.com/mitchellh/mapstructure v1.4.2 // indirect
59 60
 	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
60 61
 	github.com/modern-go/reflect2 v1.0.2 // indirect
62
+	github.com/mojocn/base64Captcha v1.3.6 // indirect
61 63
 	github.com/pelletier/go-toml v1.9.4 // indirect
62 64
 	github.com/pelletier/go-toml/v2 v2.0.6 // indirect
63 65
 	github.com/spf13/afero v1.6.0 // indirect
@@ -69,6 +71,7 @@ require (
69 71
 	github.com/ugorji/go/codec v1.2.9 // indirect
70 72
 	golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect
71 73
 	golang.org/x/crypto v0.23.0 // indirect
74
+	golang.org/x/image v0.13.0 // indirect
72 75
 	golang.org/x/net v0.25.0 // indirect
73 76
 	golang.org/x/sys v0.25.0 // indirect
74 77
 	golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect

+ 156 - 8
http/controller/admin/login.go

@@ -11,11 +11,126 @@ import (
11 11
 	"Gwen/service"
12 12
 	"fmt"
13 13
 	"github.com/gin-gonic/gin"
14
+	"github.com/mojocn/base64Captcha"
15
+	"sync"
16
+	"time"
14 17
 )
15 18
 
16 19
 type Login struct {
17 20
 }
18 21
 
22
+// Captcha 验证码结构
23
+type Captcha struct {
24
+	Id        string    `json:"id"`  // 验证码 ID
25
+	B64       string    `json:"b64"` // base64 验证码
26
+	Code      string    `json:"-"`   // 验证码内容
27
+	ExpiresAt time.Time `json:"-"`   // 过期时间
28
+}
29
+type LoginLimiter struct {
30
+	mu        sync.RWMutex
31
+	failCount map[string]int       // 记录每个 IP 的失败次数
32
+	timestamp map[string]time.Time // 记录每个 IP 的最后失败时间
33
+	captchas  map[string]Captcha   // 每个 IP 的验证码
34
+	threshold int                  // 失败阈值
35
+	expiry    time.Duration        // 失败记录过期时间
36
+}
37
+
38
+func NewLoginLimiter(threshold int, expiry time.Duration) *LoginLimiter {
39
+	return &LoginLimiter{
40
+		failCount: make(map[string]int),
41
+		timestamp: make(map[string]time.Time),
42
+		captchas:  make(map[string]Captcha),
43
+		threshold: threshold,
44
+		expiry:    expiry,
45
+	}
46
+}
47
+
48
+// RecordFailure 记录登录失败
49
+func (l *LoginLimiter) RecordFailure(ip string) {
50
+	l.mu.Lock()
51
+	defer l.mu.Unlock()
52
+
53
+	// 如果该 IP 的记录已经过期,重置计数
54
+	if lastTime, exists := l.timestamp[ip]; exists && time.Since(lastTime) > l.expiry {
55
+		l.failCount[ip] = 0
56
+	}
57
+
58
+	// 更新失败次数和时间戳
59
+	l.failCount[ip]++
60
+	l.timestamp[ip] = time.Now()
61
+}
62
+
63
+// NeedsCaptcha 检查是否需要验证码
64
+func (l *LoginLimiter) NeedsCaptcha(ip string) bool {
65
+	l.mu.RLock()
66
+	defer l.mu.RUnlock()
67
+
68
+	// 检查记录是否存在且未过期
69
+	if lastTime, exists := l.timestamp[ip]; exists && time.Since(lastTime) <= l.expiry {
70
+		return l.failCount[ip] >= l.threshold
71
+	}
72
+	return false
73
+}
74
+
75
+// GenerateCaptcha 为指定 IP 生成验证码
76
+func (l *LoginLimiter) GenerateCaptcha(ip string) Captcha {
77
+	l.mu.Lock()
78
+	defer l.mu.Unlock()
79
+
80
+	capd := base64Captcha.NewDriverString(50, 150, 5, 10, 4, "1234567890abcdefghijklmnopqrstuvwxyz", nil, nil, nil)
81
+	b64cap := base64Captcha.NewCaptcha(capd, base64Captcha.DefaultMemStore)
82
+	id, b64s, answer, err := b64cap.Generate()
83
+	if err != nil {
84
+		global.Logger.Error("Generate captcha failed: " + err.Error())
85
+		return Captcha{}
86
+	}
87
+	// 保存验证码到对应 IP
88
+	l.captchas[ip] = Captcha{
89
+		Id:        id,
90
+		B64:       b64s,
91
+		Code:      answer,
92
+		ExpiresAt: time.Now().Add(5 * time.Minute),
93
+	}
94
+	return l.captchas[ip]
95
+}
96
+
97
+// VerifyCaptcha 验证指定 IP 的验证码
98
+func (l *LoginLimiter) VerifyCaptcha(ip, code string) bool {
99
+	l.mu.RLock()
100
+	defer l.mu.RUnlock()
101
+
102
+	// 检查验证码是否存在且未过期
103
+	if captcha, exists := l.captchas[ip]; exists && time.Now().Before(captcha.ExpiresAt) {
104
+		return captcha.Code == code
105
+	}
106
+	return false
107
+}
108
+
109
+// CleanupExpired 清理过期的记录
110
+func (l *LoginLimiter) CleanupExpired() {
111
+	l.mu.Lock()
112
+	defer l.mu.Unlock()
113
+
114
+	now := time.Now()
115
+	for ip, lastTime := range l.timestamp {
116
+		if now.Sub(lastTime) > l.expiry {
117
+			delete(l.failCount, ip)
118
+			delete(l.timestamp, ip)
119
+			delete(l.captchas, ip)
120
+		}
121
+	}
122
+}
123
+func (l *LoginLimiter) RemoveRecord(ip string) {
124
+	l.mu.Lock()
125
+	defer l.mu.Unlock()
126
+
127
+	delete(l.failCount, ip)
128
+	delete(l.timestamp, ip)
129
+	delete(l.captchas, ip)
130
+}
131
+
132
+var loginLimiter = NewLoginLimiter(3, 5*time.Minute)
133
+
19 134
 // Login 登录
20 135
 // @Tags 登录
21 136
 // @Summary 登录
@@ -30,22 +145,37 @@ type Login struct {
30 145
 func (ct *Login) Login(c *gin.Context) {
31 146
 	f := &admin.Login{}
32 147
 	err := c.ShouldBindJSON(f)
148
+	clientIp := c.ClientIP()
33 149
 	if err != nil {
34
-		global.Logger.Warn(fmt.Sprintf("Login Fail: %s %s %s", "ParamsError", c.RemoteIP(), c.ClientIP()))
150
+		global.Logger.Warn(fmt.Sprintf("Login Fail: %s %s %s", "ParamsError", c.RemoteIP(), clientIp))
35 151
 		response.Fail(c, 101, response.TranslateMsg(c, "ParamsError")+err.Error())
36 152
 		return
37 153
 	}
38 154
 
39 155
 	errList := global.Validator.ValidStruct(c, f)
40 156
 	if len(errList) > 0 {
41
-		global.Logger.Warn(fmt.Sprintf("Login Fail: %s %s %s", "ParamsError", c.RemoteIP(), c.ClientIP()))
157
+		global.Logger.Warn(fmt.Sprintf("Login Fail: %s %s %s", "ParamsError", c.RemoteIP(), clientIp))
42 158
 		response.Fail(c, 101, errList[0])
43 159
 		return
44 160
 	}
161
+
162
+	// 检查是否需要验证码
163
+	if loginLimiter.NeedsCaptcha(clientIp) {
164
+		if f.Captcha == "" {
165
+			response.Fail(c, 110, response.TranslateMsg(c, "CaptchaRequired"))
166
+			return
167
+		}
168
+		if !loginLimiter.VerifyCaptcha(clientIp, f.Captcha) {
169
+			response.Fail(c, 101, response.TranslateMsg(c, "CaptchaError"))
170
+			return
171
+		}
172
+	}
173
+
45 174
 	u := service.AllService.UserService.InfoByUsernamePassword(f.Username, f.Password)
46 175
 
47 176
 	if u.Id == 0 {
48
-		global.Logger.Warn(fmt.Sprintf("Login Fail: %s %s %s", "UsernameOrPasswordError", c.RemoteIP(), c.ClientIP()))
177
+		global.Logger.Warn(fmt.Sprintf("Login Fail: %s %s %s", "UsernameOrPasswordError", c.RemoteIP(), clientIp))
178
+		loginLimiter.RecordFailure(clientIp)
49 179
 		response.Fail(c, 101, response.TranslateMsg(c, "UsernameOrPasswordError"))
50 180
 		return
51 181
 	}
@@ -54,13 +184,30 @@ func (ct *Login) Login(c *gin.Context) {
54 184
 		UserId:   u.Id,
55 185
 		Client:   model.LoginLogClientWebAdmin,
56 186
 		Uuid:     "", //must be empty
57
-		Ip:       c.ClientIP(),
187
+		Ip:       clientIp,
58 188
 		Type:     model.LoginLogTypeAccount,
59 189
 		Platform: f.Platform,
60 190
 	})
61 191
 
192
+	// 成功后清除记录
193
+	loginLimiter.RemoveRecord(clientIp)
194
+
195
+	// 清理过期记录
196
+	go loginLimiter.CleanupExpired()
197
+
62 198
 	responseLoginSuccess(c, u, ut.Token)
63 199
 }
200
+func (ct *Login) Captcha(c *gin.Context) {
201
+	clientIp := c.ClientIP()
202
+	if !loginLimiter.NeedsCaptcha(clientIp) {
203
+		response.Fail(c, 101, response.TranslateMsg(c, "NoCaptchaRequired"))
204
+		return
205
+	}
206
+	captcha := loginLimiter.GenerateCaptcha(clientIp)
207
+	response.Success(c, gin.H{
208
+		"captcha": captcha,
209
+	})
210
+}
64 211
 
65 212
 // Logout 登出
66 213
 // @Tags 登录
@@ -90,10 +237,12 @@ func (ct *Login) Logout(c *gin.Context) {
90 237
 // @Failure 500 {object} response.ErrorResponse
91 238
 // @Router /admin/login-options [post]
92 239
 func (ct *Login) LoginOptions(c *gin.Context) {
240
+	ip := c.ClientIP()
93 241
 	ops := service.AllService.OauthService.GetOauthProviders()
94 242
 	response.Success(c, gin.H{
95
-		"ops":      ops,
96
-		"register": global.Config.App.Register,
243
+		"ops":          ops,
244
+		"register":     global.Config.App.Register,
245
+		"need_captcha": loginLimiter.NeedsCaptcha(ip),
97 246
 	})
98 247
 }
99 248
 
@@ -154,11 +303,10 @@ func (ct *Login) OidcAuthQuery(c *gin.Context) {
154 303
 	responseLoginSuccess(c, u, ut.Token)
155 304
 }
156 305
 
157
-
158 306
 func responseLoginSuccess(c *gin.Context, u *model.User, token string) {
159 307
 	lp := &adResp.LoginPayload{}
160 308
 	lp.FromUser(u)
161 309
 	lp.Token = token
162 310
 	lp.RouteNames = service.AllService.UserService.RouteNames(u)
163 311
 	response.Success(c, lp)
164
-}
312
+}

+ 1 - 0
http/request/admin/login.go

@@ -4,6 +4,7 @@ type Login struct {
4 4
 	Username string `json:"username" validate:"required" label:"用户名"`
5 5
 	Password string `json:"password,omitempty" validate:"required" label:"密码"`
6 6
 	Platform string `json:"platform" label:"平台"`
7
+	Captcha  string `json:"captcha,omitempty" label:"验证码"`
7 8
 }
8 9
 
9 10
 type LoginLogQuery struct {

+ 1 - 0
http/router/admin.go

@@ -49,6 +49,7 @@ func Init(g *gin.Engine) {
49 49
 func LoginBind(rg *gin.RouterGroup) {
50 50
 	cont := &admin.Login{}
51 51
 	rg.POST("/login", cont.Login)
52
+	rg.GET("/captcha", cont.Captcha)
52 53
 	rg.POST("/logout", cont.Logout)
53 54
 	rg.GET("/login-options", cont.LoginOptions)
54 55
 	rg.POST("/oidc/auth", cont.OidcAuth)

+ 10 - 0
resources/i18n/en.toml

@@ -123,3 +123,13 @@ other = "Share Group"
123 123
 description = "Register closed."
124 124
 one = "Register closed."
125 125
 other = "Register closed."
126
+
127
+[CaptchaRequired]
128
+description = "Captcha required."
129
+one = "Captcha required."
130
+other = "Captcha required."
131
+
132
+[CaptchaError]
133
+description = "Captcha error."
134
+one = "Captcha error."
135
+other = "Captcha error."

+ 10 - 0
resources/i18n/es.toml

@@ -132,3 +132,13 @@ other = "Grupo compartido"
132 132
 description = "Register closed."
133 133
 one = "Registro cerrado."
134 134
 other = "Registro cerrado."
135
+
136
+[CaptchaRequired]
137
+description = "Captcha required."
138
+one = "Captcha requerido."
139
+other = "Captcha requerido."
140
+
141
+[CaptchaError]
142
+description = "Captcha error."
143
+one = "Error de captcha."
144
+other = "Error de captcha."

+ 11 - 1
resources/i18n/ko.toml

@@ -125,4 +125,14 @@ other = "공유 그룹"
125 125
 [RegisterClosed]
126 126
 description = "Register closed."
127 127
 one = "가입이 종료되었습니다."
128
-other = "가입이 종료되었습니다."
128
+other = "가입이 종료되었습니다."
129
+
130
+[CaptchaRequired]
131
+description = "Captcha required."
132
+one = "Captcha가 필요합니다."
133
+other = "Captcha가 필요합니다."
134
+
135
+[CaptchaError]
136
+description = "Captcha error."
137
+one = "Captcha 오류."
138
+other = "Captcha 오류."

+ 11 - 1
resources/i18n/ru.toml

@@ -131,4 +131,14 @@ other = "Общая группа"
131 131
 [RegisterClosed]
132 132
 description = "Register closed."
133 133
 one = "Регистрация закрыта."
134
-other = "Регистрация закрыта."
134
+other = "Регистрация закрыта."
135
+
136
+[CaptchaRequired]
137
+description = "Captcha required."
138
+one = "Требуется капча."
139
+other = "Требуется капча."
140
+
141
+[CaptchaError]
142
+description = "Captcha error."
143
+one = "Ошибка капчи."
144
+other = "Ошибка капчи."

+ 11 - 1
resources/i18n/zh_CN.toml

@@ -124,4 +124,14 @@ other = "共享组"
124 124
 [RegisterClosed]
125 125
 description = "Register closed."
126 126
 one = "注册已关闭。"
127
-other = "注册已关闭。"
127
+other = "注册已关闭。"
128
+
129
+[CaptchaRequired]
130
+description = "Captcha required."
131
+one = "需要验证码。"
132
+other = "需要验证码。"
133
+
134
+[CaptchaError]
135
+description = "Captcha error."
136
+one = "验证码错误。"
137
+other = "验证码错误。"