Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package config
import (
"os"
"strconv"
"time"
)

type ServerEnvironment int
Expand All @@ -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

Expand All @@ -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"),
Expand Down
3 changes: 3 additions & 0 deletions internal/handlers/users/auth/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
4 changes: 4 additions & 0 deletions internal/handlers/users/auth/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,10 @@
handlers.HandleBodilessResponse(c, http.StatusNoContent)
}

func (h *authHandler) UpdateUserBlockStatus(c *gin.Context) {
// TODO: Implement the logic to update user block status by admins

Check warning on line 264 in internal/handlers/users/auth/login.go

View check run for this annotation

Codecov / codecov/patch

internal/handlers/users/auth/login.go#L263-L264

Added lines #L263 - L264 were not covered by tests
}

/* Helper Functions */

// handleValidSession checks if the JWT token is valid and if the user session is valid.
Expand Down
52 changes: 52 additions & 0 deletions internal/repository/memory/users.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package memory

import (
"time"
"users-microservice/internal/errors"
"users-microservice/internal/models"
"users-microservice/internal/repository/users"
Expand All @@ -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),
}
}

Expand Down Expand Up @@ -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
}
47 changes: 47 additions & 0 deletions internal/repository/users/postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -224,3 +224,50 @@

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())
}

Check warning on line 236 in internal/repository/users/postgres.go

View check run for this annotation

Codecov / codecov/patch

internal/repository/users/postgres.go#L235-L236

Added lines #L235 - L236 were not covered by tests

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())
}

Check warning on line 244 in internal/repository/users/postgres.go

View check run for this annotation

Codecov / codecov/patch

internal/repository/users/postgres.go#L243-L244

Added lines #L243 - L244 were not covered by tests
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())

Check warning on line 254 in internal/repository/users/postgres.go

View check run for this annotation

Codecov / codecov/patch

internal/repository/users/postgres.go#L254

Added line #L254 was not covered by tests
}

blocked := block.BlockedUntil.After(time.Now())
if !blocked {
_ = r.UnblockUser(id)
}

Check warning on line 260 in internal/repository/users/postgres.go

View check run for this annotation

Codecov / codecov/patch

internal/repository/users/postgres.go#L259-L260

Added lines #L259 - L260 were not covered by tests

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())

Check warning on line 270 in internal/repository/users/postgres.go

View check run for this annotation

Codecov / codecov/patch

internal/repository/users/postgres.go#L270

Added line #L270 was not covered by tests
}
return nil
}
115 changes: 115 additions & 0 deletions internal/repository/users/postgres_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down
13 changes: 13 additions & 0 deletions internal/repository/users/repository.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package users

import (
"time"
"users-microservice/internal/models"

"github.com/google/uuid"
Expand Down Expand Up @@ -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
}
25 changes: 25 additions & 0 deletions internal/services/users/auth/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import (
"fmt"
"time"
"users-microservice/config"
"users-microservice/internal/errors"
"users-microservice/internal/models"
Expand Down Expand Up @@ -30,11 +31,22 @@
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())
}

Check warning on line 45 in internal/services/users/auth/login.go

View check run for this annotation

Codecov / codecov/patch

internal/services/users/auth/login.go#L44-L45

Added lines #L44 - L45 were not covered by tests

return nil, errors.NewUnauthorizedError("invalid credentials")
}
_ = s.repo.UpdateLoginRetries(user.ID, 0)

return user, nil
}
Expand Down Expand Up @@ -65,6 +77,9 @@
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")
}

Check warning on line 82 in internal/services/users/auth/login.go

View check run for this annotation

Codecov / codecov/patch

internal/services/users/auth/login.go#L81-L82

Added lines #L81 - L82 were not covered by tests

userSub, err := s.repo.GetGoogleSubByUserID(user.ID)
if err != nil || userSub != sub {
Expand All @@ -80,6 +95,9 @@
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")
}

Check warning on line 100 in internal/services/users/auth/login.go

View check run for this annotation

Codecov / codecov/patch

internal/services/users/auth/login.go#L99-L100

Added lines #L99 - L100 were not covered by tests

return user, nil
}
Expand Down Expand Up @@ -134,6 +152,13 @@
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)

Check warning on line 159 in internal/services/users/auth/login.go

View check run for this annotation

Codecov / codecov/patch

internal/services/users/auth/login.go#L155-L159

Added lines #L155 - L159 were not covered by tests
}

/* HELPER FUNCTIONS */

// googleSignup creates a user using the parsed token payload data.
Expand Down
Loading