From 516dd5cb2536de6a5b63e60c4ff64e67286491b3 Mon Sep 17 00:00:00 2001 From: maxogod Date: Tue, 10 Jun 2025 02:21:58 -0300 Subject: [PATCH] feat: user blocks for 10 secs after 5 failed login retries --- config/config.go | 7 ++ internal/handlers/users/auth/handler.go | 3 + internal/handlers/users/auth/login.go | 4 + internal/repository/memory/users.go | 52 ++++++++++ internal/repository/users/postgres.go | 47 +++++++++ internal/repository/users/postgres_test.go | 115 +++++++++++++++++++++ internal/repository/users/repository.go | 13 +++ internal/services/users/auth/login.go | 25 +++++ internal/services/users/auth/login_test.go | 31 +++++- internal/services/users/auth/service.go | 4 + 10 files changed, 300 insertions(+), 1 deletion(-) diff --git a/config/config.go b/config/config.go index 5972e98..52b9ee0 100644 --- a/config/config.go +++ b/config/config.go @@ -3,6 +3,7 @@ package config import ( "os" "strconv" + "time" ) type ServerEnvironment int @@ -28,6 +29,9 @@ type Config struct { SUPABASE_KEY string MAX_FILE_UPLOAD_SIZE int64 + MAX_LOGIN_RETRIES int + BLOCK_DURATION time.Duration + // Notifications service NOTIFICATIONS_SERVICE string @@ -53,6 +57,9 @@ func LoadConfig() *Config { SUPABASE_KEY: getEnvOrDefault("SUPABASE_KEY", ""), MAX_FILE_UPLOAD_SIZE: getMaxFileSizeOrDefault("MAX_FILE_SIZE", 10*1024*1024), // 10MB + MAX_LOGIN_RETRIES: 5, + BLOCK_DURATION: time.Second * 10, + NOTIFICATIONS_SERVICE: "http://" + getEnvOrDefault("NOTIFICATIONS_SERVICE", "") + ":8000", DD_SERVICE_NAME: getEnvOrDefault("DD_SERVICE_NAME", "users-microservice"), diff --git a/internal/handlers/users/auth/handler.go b/internal/handlers/users/auth/handler.go index da619de..44614b1 100644 --- a/internal/handlers/users/auth/handler.go +++ b/internal/handlers/users/auth/handler.go @@ -21,4 +21,7 @@ type AuthHandler interface { // RecoverPassword handles the password change process for a user or token refresh. RecoverPassword(c *gin.Context) + + // UpdateUserBlockStatus updates the user's block status based on the provided ID and block status. + UpdateUserBlockStatus(c *gin.Context) } diff --git a/internal/handlers/users/auth/login.go b/internal/handlers/users/auth/login.go index ad03a8f..b259eb0 100644 --- a/internal/handlers/users/auth/login.go +++ b/internal/handlers/users/auth/login.go @@ -260,6 +260,10 @@ func (h *authHandler) RecoverPassword(c *gin.Context) { handlers.HandleBodilessResponse(c, http.StatusNoContent) } +func (h *authHandler) UpdateUserBlockStatus(c *gin.Context) { + // TODO: Implement the logic to update user block status by admins +} + /* Helper Functions */ // handleValidSession checks if the JWT token is valid and if the user session is valid. diff --git a/internal/repository/memory/users.go b/internal/repository/memory/users.go index 714fd5c..feb12a1 100644 --- a/internal/repository/memory/users.go +++ b/internal/repository/memory/users.go @@ -1,6 +1,7 @@ package memory import ( + "time" "users-microservice/internal/errors" "users-microservice/internal/models" "users-microservice/internal/repository/users" @@ -11,12 +12,14 @@ import ( type inMemoryUsersRepository struct { usersDB map[uuid.UUID]models.User googleSubsDB map[uuid.UUID]string + blocksDB map[uuid.UUID]time.Time } func NewInMemoryUsersRepository() users.UsersRepository { return &inMemoryUsersRepository{ usersDB: make(map[uuid.UUID]models.User), googleSubsDB: make(map[uuid.UUID]string), + blocksDB: make(map[uuid.UUID]time.Time), } } @@ -123,3 +126,52 @@ func (r *inMemoryUsersRepository) ChangePassword(id uuid.UUID, newPassword strin return nil } + +func (r *inMemoryUsersRepository) BlockUser(id uuid.UUID, blockUntil time.Time) error { + _, exists := r.usersDB[id] + if !exists { + return errors.NewNotFoundError("user not found") + } + r.blocksDB[id] = blockUntil + + return nil +} + +func (r *inMemoryUsersRepository) UnblockUser(id uuid.UUID) error { + _, exists := r.usersDB[id] + if !exists { + return errors.NewNotFoundError("user not found") + } + + if _, blocked := r.blocksDB[id]; !blocked { + return errors.NewNotFoundError("user is not blocked") + } + + delete(r.blocksDB, id) + return nil +} + +func (r *inMemoryUsersRepository) IsUserBlocked(id uuid.UUID) (bool, error) { + _, exists := r.usersDB[id] + if !exists { + return false, errors.NewNotFoundError("user not found") + } + blockedUntil, blocked := r.blocksDB[id] + if blocked && blockedUntil.After(time.Now()) { + return true, nil + } + + return false, nil +} + +func (r *inMemoryUsersRepository) UpdateLoginRetries(id uuid.UUID, retries int) error { + user, exists := r.usersDB[id] + if !exists { + return errors.NewNotFoundError("user not found") + } + + user.LoginRetries = retries + r.usersDB[id] = user + + return nil +} diff --git a/internal/repository/users/postgres.go b/internal/repository/users/postgres.go index 147b382..489935c 100644 --- a/internal/repository/users/postgres.go +++ b/internal/repository/users/postgres.go @@ -224,3 +224,50 @@ func (r *usersRepository) ChangePassword(id uuid.UUID, newPassword string, token return nil } + +func (r *usersRepository) BlockUser(id uuid.UUID, blockUntil time.Time) error { + blockEntry := models.Block{ + UserID: id, + BlockedUntil: blockUntil, + } + + if err := r.db.Save(&blockEntry).Error; err != nil { + return errors.NewInternalServerError("could not block user: " + err.Error()) + } + + return nil +} + +func (r *usersRepository) UnblockUser(id uuid.UUID) error { + if err := r.db.Where("user_id = ?", id).Delete(&models.Block{}).Error; err != nil { + return errors.NewInternalServerError("could not unblock user: " + err.Error()) + } + return nil +} + +func (r *usersRepository) IsUserBlocked(id uuid.UUID) (bool, error) { + var block models.Block + if err := r.db.Where("user_id = ?", id).First(&block).Error; err != nil { + if stdErrors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + return false, errors.NewInternalServerError("could not check if user is blocked: " + err.Error()) + } + + blocked := block.BlockedUntil.After(time.Now()) + if !blocked { + _ = r.UnblockUser(id) + } + + return blocked, nil +} + +func (r *usersRepository) UpdateLoginRetries(id uuid.UUID, retries int) error { + if err := r.db.Model(&models.User{}).Where("id = ?", id).Update("login_retries", retries).Error; err != nil { + if stdErrors.Is(err, gorm.ErrRecordNotFound) { + return errors.NewNotFoundError("user not found") + } + return errors.NewInternalServerError("could not update login retries: " + err.Error()) + } + return nil +} diff --git a/internal/repository/users/postgres_test.go b/internal/repository/users/postgres_test.go index 91da7bb..e7829b7 100644 --- a/internal/repository/users/postgres_test.go +++ b/internal/repository/users/postgres_test.go @@ -513,6 +513,121 @@ func TestChangePassword_Success(t *testing.T) { assert.NoError(t, mock.ExpectationsWereMet()) } +func TestBlockUser_Success(t *testing.T) { + db, mock, cleanup := setupMockDB(t) + defer cleanup() + + repo := NewUsersRepository(db, conf) + userID := uuid.New() + + mock.ExpectBegin() + mock.ExpectExec(`UPDATE "blocks"`). + WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), userID). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + + err := repo.BlockUser(userID, time.Now().Add(time.Hour)) + assert.NoError(t, err) + + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestUnblockUser_Success(t *testing.T) { + db, mock, cleanup := setupMockDB(t) + defer cleanup() + + repo := NewUsersRepository(db, conf) + userID := uuid.New() + + mock.ExpectBegin() + mock.ExpectExec(`DELETE FROM "blocks"`). + WithArgs(userID). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + + err := repo.UnblockUser(userID) + assert.NoError(t, err) + + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestIsUserBlocked_UserNotFound(t *testing.T) { + db, mock, cleanup := setupMockDB(t) + defer cleanup() + + repo := NewUsersRepository(db, conf) + userID := uuid.New() + + mock.ExpectQuery(`SELECT \* FROM "blocks" WHERE user_id = \$1 ORDER BY "blocks"\."user_id" LIMIT \$\d+`). + WithArgs(userID, 1). + WillReturnError(gorm.ErrRecordNotFound) + + blocked, err := repo.IsUserBlocked(userID) + assert.False(t, blocked) + assert.NoError(t, err) + + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestIsUserBlocked_Success(t *testing.T) { + db, mock, cleanup := setupMockDB(t) + defer cleanup() + + repo := NewUsersRepository(db, conf) + userID := uuid.New() + + mock.ExpectQuery(`SELECT \* FROM "blocks" WHERE user_id = \$1 ORDER BY "blocks"\."user_id" LIMIT \$\d+`). + WithArgs(userID, 1). + WillReturnRows(sqlmock.NewRows([]string{"user_id", "blocked_until"}). + AddRow(userID, time.Now().Add(time.Hour))) + + blocked, err := repo.IsUserBlocked(userID) + assert.NoError(t, err) + assert.True(t, blocked) + + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestUpdateLoginRetries_NotFound(t *testing.T) { + db, mock, cleanup := setupMockDB(t) + defer cleanup() + + repo := NewUsersRepository(db, conf) + userID := uuid.New() + + mock.ExpectBegin() + mock.ExpectExec(`UPDATE "users"`). + WithArgs(1, userID). + WillReturnError(gorm.ErrRecordNotFound) + mock.ExpectRollback() + + err := repo.UpdateLoginRetries(userID, 1) + assert.Error(t, err) + + assert.IsType(t, &errors.NotFoundError{}, err) + + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestUpdateLoginRetries_Success(t *testing.T) { + db, mock, cleanup := setupMockDB(t) + defer cleanup() + + repo := NewUsersRepository(db, conf) + userID := uuid.New() + + mock.ExpectBegin() + mock.ExpectExec(`UPDATE "users"`). + WithArgs(1, userID). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + + err := repo.UpdateLoginRetries(userID, 1) + assert.NoError(t, err) + + assert.NoError(t, mock.ExpectationsWereMet()) +} + /* HELPER FUNCTIONS */ func setupMockDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) { diff --git a/internal/repository/users/repository.go b/internal/repository/users/repository.go index 3aad468..327f403 100644 --- a/internal/repository/users/repository.go +++ b/internal/repository/users/repository.go @@ -1,6 +1,7 @@ package users import ( + "time" "users-microservice/internal/models" "github.com/google/uuid" @@ -36,4 +37,16 @@ type UsersRepository interface { // ChangePassword changes the password for a user. ChangePassword(id uuid.UUID, newPassword string, token string) error + + // BlockUser blocks a user by their ID for a given time. + BlockUser(id uuid.UUID, blockUntil time.Time) error + + // UnblockUser unblocks a user by their ID. + UnblockUser(id uuid.UUID) error + + // IsUserBlocked checks if a user is blocked by their ID. + IsUserBlocked(id uuid.UUID) (bool, error) + + // UpdateLoginRetries updates the number of login retries for a user. + UpdateLoginRetries(id uuid.UUID, retries int) error } diff --git a/internal/services/users/auth/login.go b/internal/services/users/auth/login.go index c48d35a..acbcce8 100644 --- a/internal/services/users/auth/login.go +++ b/internal/services/users/auth/login.go @@ -2,6 +2,7 @@ package auth import ( "fmt" + "time" "users-microservice/config" "users-microservice/internal/errors" "users-microservice/internal/models" @@ -30,11 +31,22 @@ func (s *authService) Login(email, password string) (*models.User, error) { if err != nil { return nil, errors.NewUnauthorizedError("email is not linked to any account") } + if blocked, err := s.repo.IsUserBlocked(user.ID); blocked || err != nil { + return nil, errors.NewUnauthorizedError("user is blocked or an error occurred") + } err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) if err != nil { + if user.LoginRetries < s.conf.MAX_LOGIN_RETRIES { + println("Login attempt for user:", user.LoginRetries, user.Email) + _ = s.repo.UpdateLoginRetries(user.ID, user.LoginRetries+1) + } else if err := s.repo.BlockUser(user.ID, time.Now().Add(s.conf.BLOCK_DURATION)); err != nil { + return nil, errors.NewInternalServerError("could not block user: " + err.Error()) + } + return nil, errors.NewUnauthorizedError("invalid credentials") } + _ = s.repo.UpdateLoginRetries(user.ID, 0) return user, nil } @@ -65,6 +77,9 @@ func (s *authService) GoogleLogin(gToken, gType string, created *bool) (*models. return s.googleSignup(payload) } *created = false + if blocked, err := s.repo.IsUserBlocked(user.ID); blocked || err != nil { + return nil, errors.NewUnauthorizedError("user is blocked or an error occurred") + } userSub, err := s.repo.GetGoogleSubByUserID(user.ID) if err != nil || userSub != sub { @@ -80,6 +95,9 @@ func (s *authService) VerifySession(id uuid.UUID, invalid, expired bool) (*model if invalid || expired || err != nil { return nil, errors.NewUnauthorizedError("invalid JWT token") } + if blocked, err := s.repo.IsUserBlocked(user.ID); blocked || err != nil { + return nil, errors.NewUnauthorizedError("user is blocked or an error occurred") + } return user, nil } @@ -134,6 +152,13 @@ func (s *authService) ChangePassword(email string, newPassword, token string) er return nil } +func (s *authService) UpdateUserBlockStatus(id uuid.UUID, blockedUntil time.Time, block bool) error { + if block { + return s.repo.BlockUser(id, blockedUntil) + } + return s.repo.UnblockUser(id) +} + /* HELPER FUNCTIONS */ // googleSignup creates a user using the parsed token payload data. diff --git a/internal/services/users/auth/login_test.go b/internal/services/users/auth/login_test.go index a7a2767..2279e67 100644 --- a/internal/services/users/auth/login_test.go +++ b/internal/services/users/auth/login_test.go @@ -2,6 +2,7 @@ package auth_test import ( "testing" + "time" "users-microservice/config" "users-microservice/internal/errors" "users-microservice/internal/models" @@ -52,7 +53,9 @@ func TestMain(m *testing.M) { utils.VerifyGoogleToken = mockVerifyGoogleLogin conf := &config.Config{ - GOOGLE_CLIENT_ID: "test-client-id", + GOOGLE_CLIENT_ID: "test-client-id", + MAX_LOGIN_RETRIES: 2, + BLOCK_DURATION: time.Minute * 10, } authService = auth.NewAuthService(repository, conf) @@ -238,6 +241,32 @@ func TestPasswordRecovery(t *testing.T) { assert.NoError(t, err) } +func TestBlockUserAfterLoginRetries(t *testing.T) { + // First try + user, err := authService.Login("test3@email.com", "bad-cred") + assert.Nil(t, user) + assert.Error(t, err) + assert.NotContains(t, err.Error(), "blocked") + + // Second try + user, err = authService.Login("test3@email.com", "bad-cred") + assert.Nil(t, user) + assert.Error(t, err) + assert.NotContains(t, err.Error(), "blocked") + + // Third try + user, err = authService.Login("test3@email.com", "bad-cred") + assert.Nil(t, user) + assert.Error(t, err) + assert.NotContains(t, err.Error(), "blocked") + + // Third try should block the user + user, err = authService.Login("test3@email.com", "bad-cred") + assert.Nil(t, user) + assert.Error(t, err) + assert.Contains(t, err.Error(), "user is blocked or an error occurred") +} + /* HELPER FUNCTIONS */ // mockVerifyGoogleLogin is a mock function to simulate Google token verification. diff --git a/internal/services/users/auth/service.go b/internal/services/users/auth/service.go index c1a02b5..89796db 100644 --- a/internal/services/users/auth/service.go +++ b/internal/services/users/auth/service.go @@ -1,6 +1,7 @@ package auth import ( + "time" "users-microservice/internal/models" "github.com/google/uuid" @@ -29,4 +30,7 @@ type AuthService interface { // ChangePassword changes the user's password using the provided email, new password, and token. ChangePassword(email string, newPassword, token string) error + + // UpdateUserBlockStatus updates the user's block status based on the provided ID and block status. + UpdateUserBlockStatus(id uuid.UUID, blockUntil time.Time, block bool) error }