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