fix(auth): keep lockout window stable
This commit is contained in:
@@ -81,23 +81,24 @@ func (s *authService) RequirePermission(ctx iris.Context, permission constants.P
|
|||||||
|
|
||||||
func (s *authService) Login(req request.LoginRequest, authCfg config.AuthConfig, clientIP, userAgent string) (*response.LoginResponse, error) {
|
func (s *authService) Login(req request.LoginRequest, authCfg config.AuthConfig, clientIP, userAgent string) (*response.LoginResponse, error) {
|
||||||
username := strings.TrimSpace(req.Username)
|
username := strings.TrimSpace(req.Username)
|
||||||
|
principal := normalizeLoginPrincipal(username)
|
||||||
password := req.Password
|
password := req.Password
|
||||||
if username == "" || strings.TrimSpace(password) == "" {
|
if username == "" || strings.TrimSpace(password) == "" {
|
||||||
return nil, errorsx.InvalidParam("用户名和密码不能为空")
|
return nil, errorsx.InvalidParam("用户名和密码不能为空")
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.isCredentialLocked(username, authCfg) {
|
if s.isCredentialLocked(principal, authCfg) {
|
||||||
_ = s.createLoginCredentialLog(username, 0, false, clientIP, userAgent, "credential locked")
|
_ = s.createLoginCredentialLog(principal, 0, false, clientIP, userAgent, "credential locked")
|
||||||
return nil, errorsx.CredentialLocked("登录失败次数过多,请稍后再试")
|
return nil, errorsx.CredentialLocked("登录失败次数过多,请稍后再试")
|
||||||
}
|
}
|
||||||
|
|
||||||
user := UserService.GetByUsername(username)
|
user := UserService.GetByUsername(username)
|
||||||
if user == nil || user.Status != enums.StatusOk {
|
if user == nil || user.Status != enums.StatusOk {
|
||||||
_ = s.createLoginCredentialLog(username, 0, false, clientIP, userAgent, "user not found")
|
_ = s.createLoginCredentialLog(principal, 0, false, clientIP, userAgent, "user not found")
|
||||||
return nil, errorsx.InvalidAccount("用户名或密码错误")
|
return nil, errorsx.InvalidAccount("用户名或密码错误")
|
||||||
}
|
}
|
||||||
if strs.IsBlank(user.Password) || bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) != nil {
|
if strs.IsBlank(user.Password) || bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) != nil {
|
||||||
_ = s.createLoginCredentialLog(username, user.ID, false, clientIP, userAgent, "password mismatch")
|
_ = s.createLoginCredentialLog(principal, user.ID, false, clientIP, userAgent, "password mismatch")
|
||||||
return nil, errorsx.InvalidAccount("用户名或密码错误")
|
return nil, errorsx.InvalidAccount("用户名或密码错误")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +123,7 @@ func (s *authService) Login(req request.LoginRequest, authCfg config.AuthConfig,
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = s.createLoginCredentialLog(username, user.ID, true, clientIP, userAgent, "")
|
_ = s.createLoginCredentialLog(principal, user.ID, true, clientIP, userAgent, "")
|
||||||
return ret, nil
|
return ret, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,7 +406,7 @@ func (s *authService) createLoginCredentialLog(principal string, userID int64, s
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *authService) isCredentialLocked(username string, authCfg config.AuthConfig) bool {
|
func (s *authService) isCredentialLocked(principal string, authCfg config.AuthConfig) bool {
|
||||||
maxFailedAttempts := authCfg.MaxFailedAttempts
|
maxFailedAttempts := authCfg.MaxFailedAttempts
|
||||||
if maxFailedAttempts <= 0 {
|
if maxFailedAttempts <= 0 {
|
||||||
return false
|
return false
|
||||||
@@ -416,11 +417,16 @@ func (s *authService) isCredentialLocked(username string, authCfg config.AuthCon
|
|||||||
}
|
}
|
||||||
since := time.Now().Add(-time.Duration(lockMinute) * time.Minute)
|
since := time.Now().Add(-time.Duration(lockMinute) * time.Minute)
|
||||||
return LoginCredentialLogService.Count(sqls.NewCnd().
|
return LoginCredentialLogService.Count(sqls.NewCnd().
|
||||||
Eq("principal", username).
|
Eq("principal", normalizeLoginPrincipal(principal)).
|
||||||
Eq("success", false).
|
Eq("success", false).
|
||||||
|
NotEq("reason", "credential locked").
|
||||||
Where("created_at >= ?", since)) >= int64(maxFailedAttempts)
|
Where("created_at >= ?", since)) >= int64(maxFailedAttempts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeLoginPrincipal(principal string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(principal))
|
||||||
|
}
|
||||||
|
|
||||||
func randomToken(prefix string) (string, error) {
|
func randomToken(prefix string) (string, error) {
|
||||||
buf := make([]byte, 24)
|
buf := make([]byte, 24)
|
||||||
if _, err := rand.Read(buf); err != nil {
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
|||||||
@@ -155,6 +155,74 @@ func TestAuthServiceLoginCredentialLockout(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAuthServiceCredentialLockoutDoesNotExtendWhileLocked(t *testing.T) {
|
||||||
|
db := setupAuthServiceTestDB(t)
|
||||||
|
createAuthTestUser(t, db, "admin", "secret")
|
||||||
|
now := time.Now()
|
||||||
|
entries := []models.LoginCredentialLog{
|
||||||
|
{
|
||||||
|
Principal: "admin",
|
||||||
|
UserID: 1,
|
||||||
|
Success: false,
|
||||||
|
Reason: "password mismatch",
|
||||||
|
CreatedAt: now.Add(-2 * time.Minute),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Principal: "admin",
|
||||||
|
UserID: 0,
|
||||||
|
Success: false,
|
||||||
|
Reason: "credential locked",
|
||||||
|
CreatedAt: now.Add(-1 * time.Minute),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := db.Create(&entries).Error; err != nil {
|
||||||
|
t.Fatalf("seed credential logs: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ret, err := newAuthService().Login(request.LoginRequest{Username: "admin", Password: "secret"}, config.AuthConfig{
|
||||||
|
TokenTTLHours: 2,
|
||||||
|
MaxFailedAttempts: 2,
|
||||||
|
CredentialLockMinute: 15,
|
||||||
|
}, "127.0.0.1", "go-test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected locked attempt logs not to extend lockout, got %v", err)
|
||||||
|
}
|
||||||
|
if ret == nil || !strings.HasPrefix(ret.AccessToken, "ak_") {
|
||||||
|
t.Fatalf("expected login response with ak_ token, got %+v", ret)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthServiceCredentialLockoutNormalizesPrincipalCase(t *testing.T) {
|
||||||
|
db := setupAuthServiceTestDB(t)
|
||||||
|
createAuthTestUser(t, db, "admin", "secret")
|
||||||
|
if err := db.Create(&models.LoginCredentialLog{
|
||||||
|
Principal: "admin",
|
||||||
|
UserID: 1,
|
||||||
|
Success: false,
|
||||||
|
Reason: "password mismatch",
|
||||||
|
CreatedAt: time.Now().Add(-time.Minute),
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("seed credential log: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := newAuthService().Login(request.LoginRequest{Username: "ADMIN", Password: "secret"}, config.AuthConfig{
|
||||||
|
TokenTTLHours: 2,
|
||||||
|
MaxFailedAttempts: 1,
|
||||||
|
CredentialLockMinute: 15,
|
||||||
|
}, "127.0.0.1", "go-test")
|
||||||
|
if !hasCode(err, errorsx.CodeAuthCredentialLocked) {
|
||||||
|
t.Fatalf("expected normalized principal to be locked, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var lockedLog models.LoginCredentialLog
|
||||||
|
if err := db.Order("id DESC").Take(&lockedLog).Error; err != nil {
|
||||||
|
t.Fatalf("query latest credential log: %v", err)
|
||||||
|
}
|
||||||
|
if lockedLog.Principal != "admin" || lockedLog.Reason != "credential locked" {
|
||||||
|
t.Fatalf("unexpected locked log: %+v", lockedLog)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAuthServiceCredentialLockoutDisabledWhenMaxAttemptsNonPositive(t *testing.T) {
|
func TestAuthServiceCredentialLockoutDisabledWhenMaxAttemptsNonPositive(t *testing.T) {
|
||||||
db := setupAuthServiceTestDB(t)
|
db := setupAuthServiceTestDB(t)
|
||||||
user := createAuthTestUser(t, db, "admin", "secret")
|
user := createAuthTestUser(t, db, "admin", "secret")
|
||||||
|
|||||||
Reference in New Issue
Block a user