diff --git a/internal/services/auth_service.go b/internal/services/auth_service.go index bcf5966..63d701b 100644 --- a/internal/services/auth_service.go +++ b/internal/services/auth_service.go @@ -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) { username := strings.TrimSpace(req.Username) + principal := normalizeLoginPrincipal(username) password := req.Password if username == "" || strings.TrimSpace(password) == "" { return nil, errorsx.InvalidParam("用户名和密码不能为空") } - if s.isCredentialLocked(username, authCfg) { - _ = s.createLoginCredentialLog(username, 0, false, clientIP, userAgent, "credential locked") + if s.isCredentialLocked(principal, authCfg) { + _ = s.createLoginCredentialLog(principal, 0, false, clientIP, userAgent, "credential locked") return nil, errorsx.CredentialLocked("登录失败次数过多,请稍后再试") } user := UserService.GetByUsername(username) 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("用户名或密码错误") } 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("用户名或密码错误") } @@ -122,7 +123,7 @@ func (s *authService) Login(req request.LoginRequest, authCfg config.AuthConfig, return nil, err } - _ = s.createLoginCredentialLog(username, user.ID, true, clientIP, userAgent, "") + _ = s.createLoginCredentialLog(principal, user.ID, true, clientIP, userAgent, "") 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 if maxFailedAttempts <= 0 { return false @@ -416,11 +417,16 @@ func (s *authService) isCredentialLocked(username string, authCfg config.AuthCon } since := time.Now().Add(-time.Duration(lockMinute) * time.Minute) return LoginCredentialLogService.Count(sqls.NewCnd(). - Eq("principal", username). + Eq("principal", normalizeLoginPrincipal(principal)). Eq("success", false). + NotEq("reason", "credential locked"). Where("created_at >= ?", since)) >= int64(maxFailedAttempts) } +func normalizeLoginPrincipal(principal string) string { + return strings.ToLower(strings.TrimSpace(principal)) +} + func randomToken(prefix string) (string, error) { buf := make([]byte, 24) if _, err := rand.Read(buf); err != nil { diff --git a/internal/services/auth_service_test.go b/internal/services/auth_service_test.go index 4c85e49..f30a9b0 100644 --- a/internal/services/auth_service_test.go +++ b/internal/services/auth_service_test.go @@ -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) { db := setupAuthServiceTestDB(t) user := createAuthTestUser(t, db, "admin", "secret")