diff --git a/README.md b/README.md index 23cd283..abf568f 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,53 @@ [![codecov](https://codecov.io/gh/ClassConnect-org/users-microservice/graph/badge.svg?token=I5YHR5YBCF)](https://codecov.io/gh/ClassConnect-org/users-microservice) -### Test Coverage Grid +## Descripcion -![coverage grid](https://codecov.io/gh/ClassConnect-org/users-microservice/graphs/tree.svg?token=I5YHR5YBCF) +Este microservicio tiene como responsabilidad el manejo de autenticacion de usuarios, +ya sea creacion, login, validacion de email y demas datos, +el manejo de sesiones utilizando stateless sessions con jwt tokens necesarias para el resto +de los microservicios y el manejo de los perfiles incluyendo ediciones, almacenamiento de imagenes y proteccion de datos privados. + +## Endpoints + +Los endpoints de este microservicio se pueden encontrar en el *swagger* del mismo (o alternativamente en la carpeta `docs/`), +los mismos son: + +* `GET` **/auth** (Validar sesion actual) +* `GET` **/auth/activate/:id?token=string&refresh=bool** (Activacion de cuenta) +* `POST` **/auth/google** (Inicio de sesion/Creacion de cuenta con Google) +* `POST` **/auth/login** (Inicio de sesion con email y password) +* `POST` **/signup** (Creacion de cuenta con email y password) +* `GET` **/profile** (Obtener perfil de la sesion actual) +* `PATCH` **/profile** (Editar perfil de la sesion actual) +* `PUT` **/profile/picture** (Subir imagen como foto de perfil) +* `DELETE` **/profile/picture** (Borrar foto de perfil) +* `GET` **/profile/:id** (Obtener perfil de un usuario dado) + +## Estructura + +Se utiliza la arquitectura package by layer, donde los controladores se pueden encontrar en la +carpeta *handlers*, los servicios en *services* y los repositorios en *repository*. + +Para cada grupo de rutas hay una interfaz para el Handler y otra para el Service, sin embargo +solamente hay dos interfaces diferentes para repositorios (una de users repository y otra de profiles repository). + +## Desarrollo local + +### Docker-compose de uso local + +```bash +docker compose -f local-dev-compose.yml up --build +``` + +### Migraciones + +```bash +migrate create -ext sql -dir migrations -seq create_assignments_table +``` + +## Despliegue + +### Pipelines + +### Como desplegar a produccion 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/docs/docs.go b/docs/docs.go index c00116e..29a85e6 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -117,6 +117,12 @@ const docTemplate = `{ ], "summary": "Google Login \u0026 Signup", "parameters": [ + { + "type": "boolean", + "description": "Confirm account merge (true/false) - Optional, defaults to false", + "name": "confirm_account_merge", + "in": "query" + }, { "description": "Google login data", "name": "request", @@ -151,6 +157,12 @@ const docTemplate = `{ "schema": { "$ref": "#/definitions/dto.ErrorResponse" } + }, + "409": { + "description": "Email is already linked to a non-google account, confirm account merge", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } } } } diff --git a/docs/swagger.json b/docs/swagger.json index dae6127..251e6d1 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -110,6 +110,12 @@ ], "summary": "Google Login \u0026 Signup", "parameters": [ + { + "type": "boolean", + "description": "Confirm account merge (true/false) - Optional, defaults to false", + "name": "confirm_account_merge", + "in": "query" + }, { "description": "Google login data", "name": "request", @@ -144,6 +150,12 @@ "schema": { "$ref": "#/definitions/dto.ErrorResponse" } + }, + "409": { + "description": "Email is already linked to a non-google account, confirm account merge", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } } } } diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 4b99325..6715694 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -185,6 +185,10 @@ paths: description: Login a user with Google authentication or Create it if it doesn't exist parameters: + - description: Confirm account merge (true/false) - Optional, defaults to false + in: query + name: confirm_account_merge + type: boolean - description: Google login data in: body name: request @@ -210,6 +214,11 @@ paths: description: Invalid credentials or invalid JWT token schema: $ref: '#/definitions/dto.ErrorResponse' + "409": + description: Email is already linked to a non-google account, confirm account + merge + schema: + $ref: '#/definitions/dto.ErrorResponse' summary: Google Login & Signup tags: - users 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..0a88048 100644 --- a/internal/handlers/users/auth/login.go +++ b/internal/handlers/users/auth/login.go @@ -81,11 +81,13 @@ func (h *authHandler) Login(c *gin.Context) { // @Tags users // @Accept json // @Produce json +// @Param confirm_account_merge query boolean false "Confirm account merge (true/false) - Optional, defaults to false" // @Param request body dto.GoogleAuthDTO true "Google login data" // @Success 200 {object} dto.UserResponseDTO "Successful login (Wrapped in data envelope)" // @Success 201 {object} dto.UserResponseDTO "User created (Wrapped in data envelope)" // @Failure 400 {object} dto.ErrorResponse "Bad request body" // @Failure 401 {object} dto.ErrorResponse "Invalid credentials or invalid JWT token" +// @Failure 409 {object} dto.ErrorResponse "Email is already linked to a non-google account, confirm account merge" // @Router /auth/google [post] func (h *authHandler) GoogleLogin(c *gin.Context) { if c.GetBool("includedJWT") { @@ -101,8 +103,10 @@ func (h *authHandler) GoogleLogin(c *gin.Context) { return } + confirmAccountMerge := c.Query("confirm_account_merge") == "true" + created := false - user, err := h.service.GoogleLogin(googleLoginInfo.IdToken, googleLoginInfo.Type, &created) + user, err := h.service.GoogleLogin(googleLoginInfo.IdToken, googleLoginInfo.Type, &created, confirmAccountMerge) if err != nil { _ = c.Error(err) return @@ -260,6 +264,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/handlers/users/auth/login_test.go b/internal/handlers/users/auth/login_test.go index c7a209e..a8d3b3b 100644 --- a/internal/handlers/users/auth/login_test.go +++ b/internal/handlers/users/auth/login_test.go @@ -299,7 +299,7 @@ func TestAccountActivation_RefreshToken(t *testing.T) { assert.NotContains(t, w.Header().Get("Authorization"), "Bearer") } -func TestAccountActivation(t *testing.T) { +func TestPasswordRecovery(t *testing.T) { user, _ := repo.GetUserByEmail("test2@email.com") pwRecoveryDTO := &dto.PasswordRecoveryDTO{ @@ -321,6 +321,8 @@ func TestAccountActivation(t *testing.T) { assert.Equal(t, http.StatusNoContent, w.Code, "should return 204 if refresh is successful") // invalid token + pwRecoveryDTO.Password = "newpassword123" + body, _ = json.Marshal(pwRecoveryDTO) req, _ = http.NewRequest("POST", "/auth/recovery?token=invalid-token", bytes.NewBuffer(body)) w = httptest.NewRecorder() router.ServeHTTP(w, req) diff --git a/internal/models/blocks.go b/internal/models/blocks.go new file mode 100644 index 0000000..f972475 --- /dev/null +++ b/internal/models/blocks.go @@ -0,0 +1,15 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +// Block represents a user block in the database. +type Block struct { + UserID uuid.UUID `gorm:"type:uuid;not null;primaryKey"` + BlockedUntil time.Time `gorm:"not null"` + CreatedAt time.Time `gorm:"not null;default:current_timestamp"` + UpdatedAt time.Time `gorm:"not null;default:current_timestamp"` +} diff --git a/internal/models/user.go b/internal/models/user.go index 54a9021..650e385 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -6,9 +6,10 @@ import ( // User represents the user table in the database. type User struct { - ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey"` - Name string `gorm:"not null"` - Email string `gorm:"unique;not null"` - Password string `gorm:"not null"` - Active bool `gorm:"default:false"` + ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey"` + Name string `gorm:"not null"` + Email string `gorm:"unique;not null"` + Password string `gorm:"not null"` + Active bool `gorm:"default:false"` + LoginRetries int `gorm:"default:0"` } diff --git a/internal/repository/memory/users.go b/internal/repository/memory/users.go index 714fd5c..ff25764 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), } } @@ -48,6 +51,21 @@ func (r *inMemoryUsersRepository) CreateGoogleUser(user *models.User, _picture, return nil } +func (r *inMemoryUsersRepository) MergeGoogleUser(userID uuid.UUID, name, picture, sub string) (*models.User, error) { + user, exists := r.usersDB[userID] + if !exists { + return nil, errors.NewNotFoundError("user not found") + } + + user.Name = name + user.Active = true + r.usersDB[userID] = user + + r.googleSubsDB[userID] = sub + + return &user, nil +} + /* READ OPERATIONS */ func (r *inMemoryUsersRepository) GetUserByID(id uuid.UUID) (*models.User, error) { @@ -123,3 +141,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..43c7a8d 100644 --- a/internal/repository/users/postgres.go +++ b/internal/repository/users/postgres.go @@ -86,6 +86,45 @@ func (r *usersRepository) CreateGoogleUser(user *models.User, picture, sub strin }) } +func (r *usersRepository) MergeGoogleUser(userID uuid.UUID, name, picture, sub string) (*models.User, error) { + var user models.User + if err := r.db.First(&user, userID).Error; err != nil { + if stdErrors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.NewNotFoundError("user not found") + } + return nil, errors.NewInternalServerError("could not find user: " + err.Error()) + } + + user.Name = name + user.Active = true + if err := r.db.Save(&user).Error; err != nil { + return nil, errors.NewInternalServerError("could not update user: " + err.Error()) + } + + var profile models.Profile + if err := r.db.Where("user_id = ?", user.ID).First(&profile).Error; err != nil { + if stdErrors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.NewNotFoundError("profile not found") + } + return nil, errors.NewInternalServerError("could not find profile: " + err.Error()) + } + + profile.Picture = picture + if err := r.db.Save(&profile).Error; err != nil { + return nil, errors.NewInternalServerError("could not update profile: " + err.Error()) + } + + googleSub := &models.GoogleSub{ + UserID: user.ID, + Sub: sub, + } + if err := r.db.Create(googleSub).Error; err != nil { + return nil, errors.NewInternalServerError("could not update google sub: " + err.Error()) + } + + return &user, nil +} + /* READ OPERATIONS */ func (r *usersRepository) GetUserByID(id uuid.UUID) (*models.User, error) { @@ -224,3 +263,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 8ee2320..5aa2cd1 100644 --- a/internal/repository/users/postgres_test.go +++ b/internal/repository/users/postgres_test.go @@ -39,7 +39,7 @@ func TestCreateUser_CreateUserFails(t *testing.T) { mock.ExpectBegin() mock.ExpectQuery(`INSERT INTO "users"`). - WithArgs(user.Name, user.Email, user.Password, false, user.ID). + WithArgs(user.Name, user.Email, user.Password, false, 0, user.ID). WillReturnError(stdErrors.New("insert failed")) mock.ExpectRollback() @@ -66,7 +66,7 @@ func TestCreateUser_CreateProfileFails(t *testing.T) { mock.ExpectBegin() mock.ExpectQuery(`INSERT INTO "users"`). - WithArgs(user.Name, user.Email, user.Password, false, user.ID). + WithArgs(user.Name, user.Email, user.Password, false, 0, user.ID). WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(user.ID)) mock.ExpectQuery(`INSERT INTO "profiles"`). @@ -105,7 +105,7 @@ func TestCreateUser_Success(t *testing.T) { mock.ExpectBegin() mock.ExpectQuery(`INSERT INTO "users"`). - WithArgs(user.Name, user.Email, user.Password, false, user.ID). + WithArgs(user.Name, user.Email, user.Password, false, 0, user.ID). WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(user.ID)) mock.ExpectQuery(`INSERT INTO "profiles"`). @@ -158,7 +158,7 @@ func TestCreateGoogleUser_Success(t *testing.T) { mock.ExpectBegin() mock.ExpectQuery(`INSERT INTO "users"`). - WithArgs(user.Name, user.Email, user.Password, true, user.ID). + WithArgs(user.Name, user.Email, user.Password, true, 0, user.ID). WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(user.ID)) mock.ExpectQuery(`INSERT INTO "profiles"`). @@ -187,6 +187,56 @@ func TestCreateGoogleUser_Success(t *testing.T) { assert.NoError(t, mock.ExpectationsWereMet()) } +func TestMergeGoogleUser_Success(t *testing.T) { + db, mock, cleanup := setupMockDB(t) + defer cleanup() + + repo := NewUsersRepository(db, conf) + userID := uuid.New() + picture := "http://example.com/picture.jpg" + name := "New Name" + + mock.ExpectQuery(`SELECT \* FROM "users" WHERE "users"\."id" = \$1 ORDER BY "users"\."id" LIMIT \$2`). + WithArgs(userID, 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "email"}).AddRow(userID, "test@example.com")) + + mock.ExpectBegin() + mock.ExpectExec(`UPDATE "users"`). + WithArgs(name, "test@example.com", sqlmock.AnyArg(), true, sqlmock.AnyArg(), userID). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + + mock.ExpectQuery(`SELECT \* FROM "profiles" WHERE user_id = \$1 ORDER BY "profiles"\."user_id" LIMIT \$2`). + WithArgs(userID, 1). + WillReturnRows(sqlmock.NewRows([]string{"user_id"}).AddRow(userID)) + + mock.ExpectBegin() + mock.ExpectExec(`UPDATE "profiles"`). + WithArgs( + picture, + 0, + "", + "", + "", + false, + sqlmock.AnyArg(), + sqlmock.AnyArg(), + userID, + ). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + + mock.ExpectBegin() + mock.ExpectExec(`INSERT INTO "google_subs"`). + WithArgs(userID, "sub-id"). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + + user, err := repo.MergeGoogleUser(userID, name, picture, "sub-id") + assert.NoError(t, err) + assert.Equal(t, userID, user.ID) +} + /* GET USERS */ func TestGetUserByID_UserNotFound(t *testing.T) { @@ -199,7 +249,7 @@ func TestGetUserByID_UserNotFound(t *testing.T) { mock.ExpectQuery(`SELECT \* FROM "users" WHERE "users"\."id" = \$1 ORDER BY "users"\."id" LIMIT \$\d+`). WithArgs(userID, 1). - WillReturnRows(sqlmock.NewRows([]string{"id", "name", "email", "password"})) + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "email", "password", "login_retries"})) user, err := repo.GetUserByID(userID) @@ -230,8 +280,8 @@ func TestGetUserByID_Success(t *testing.T) { mock.ExpectQuery(`SELECT \* FROM "users" WHERE "users"\."id" = \$1 ORDER BY "users"\."id" LIMIT \$\d+`). WithArgs(user.ID, 1). - WillReturnRows(sqlmock.NewRows([]string{"id", "name", "email", "password"}). - AddRow(user.ID, user.Name, user.Email, user.Password)) + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "email", "password", "login_retries"}). + AddRow(user.ID, user.Name, user.Email, user.Password, user.LoginRetries)) result, err := repo.GetUserByID(user.ID) assert.NoError(t, err) @@ -251,7 +301,7 @@ func TestGetUserByEmail_UserNotFound(t *testing.T) { mock.ExpectQuery(`SELECT \* FROM "users" WHERE email = \$1 ORDER BY "users"\."id" LIMIT \$\d+`). WithArgs(email, 1). - WillReturnRows(sqlmock.NewRows([]string{"id", "name", "email", "password"})) + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "email", "password", "login_retries"})) user, err := repo.GetUserByEmail(email) assert.Nil(t, user) @@ -279,8 +329,8 @@ func TestGetUserByEmail_Success(t *testing.T) { mock.ExpectQuery(`SELECT \* FROM "users" WHERE email = \$1 ORDER BY "users"\."id" LIMIT \$\d+`). WithArgs(user.Email, 1). - WillReturnRows(sqlmock.NewRows([]string{"id", "name", "email", "password"}). - AddRow(user.ID, user.Name, user.Email, user.Password)) + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "email", "password", "login_retries"}). + AddRow(user.ID, user.Name, user.Email, user.Password, user.LoginRetries)) result, err := repo.GetUserByEmail(user.Email) assert.NoError(t, err) @@ -404,8 +454,8 @@ func TestRefreshToken_Success(t *testing.T) { mock.ExpectQuery(`SELECT \* FROM "users" WHERE "users"\."id" = \$1 ORDER BY "users"\."id" LIMIT \$\d+`). WithArgs(user.ID, 1). - WillReturnRows(sqlmock.NewRows([]string{"id", "name", "email", "password"}). - AddRow(user.ID, user.Name, user.Email, user.Password)) + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "email", "password", "login_retries"}). + AddRow(user.ID, user.Name, user.Email, user.Password, user.LoginRetries)) err := repo.RefreshActivationToken(userID) assert.NoError(t, err) @@ -433,8 +483,8 @@ func TestRefreshPasswordRecoveryToken(t *testing.T) { mock.ExpectQuery(`SELECT \* FROM "users" WHERE "users"\."id" = \$1 ORDER BY "users"\."id" LIMIT \$\d+`). WithArgs(userID, 1). - WillReturnRows(sqlmock.NewRows([]string{"id", "name", "email", "password"}). - AddRow(userID, "name", "email", "password")) + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "email", "password", "login_retries"}). + AddRow(userID, "name", "email", "password", 0)) err := repo.RefreshPasswordRecoveryCode(userID) assert.NoError(t, err) @@ -513,6 +563,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..17a4559 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" @@ -16,6 +17,9 @@ type UsersRepository interface { // and initializes its profile with its picture. CreateGoogleUser(user *models.User, picture, sub string) error + // MergeGoogleUser merges a Google user with an existing user account. + MergeGoogleUser(userID uuid.UUID, name, picture, sub string) (*models.User, error) + // GetUserByID retrieves a user by their ID. GetUserByID(id uuid.UUID) (*models.User, error) @@ -36,4 +40,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..767d424 100644 --- a/internal/services/users/auth/login.go +++ b/internal/services/users/auth/login.go @@ -1,7 +1,9 @@ package auth import ( + stdErrors "errors" "fmt" + "time" "users-microservice/config" "users-microservice/internal/errors" "users-microservice/internal/models" @@ -30,18 +32,28 @@ 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 { + _ = 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 } // GoogleLogin handles Google login for users and creates them if necessary. // It verifies the Google ID token and retrieves user information from Google. -func (s *authService) GoogleLogin(gToken, gType string, created *bool) (*models.User, error) { +func (s *authService) GoogleLogin(gToken, gType string, created *bool, confirmAccMerge bool) (*models.User, error) { if gType != "success" { return nil, errors.NewUnauthorizedError("invalid google login type") } @@ -65,9 +77,22 @@ 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") + } else if confirmAccMerge { + user, err = s.mergeGoogleUserData(user.ID, payload) + if err != nil { + return nil, err + } + } userSub, err := s.repo.GetGoogleSubByUserID(user.ID) if err != nil || userSub != sub { + var notFoundErr *errors.NotFoundError + if err != nil && stdErrors.As(err, ¬FoundErr) && user.Password != "" { + // Password is empty if user was created via Google signup + return nil, errors.NewConflictError("this email is already linked to a non-Google account, please confirm account merge") + } return nil, errors.NewUnauthorizedError("invalid google authentication token") } @@ -80,6 +105,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 +162,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. @@ -158,3 +193,14 @@ func (s *authService) googleSignup(payload *idtoken.Payload) (*models.User, erro return user, nil } + +func (s *authService) mergeGoogleUserData(userID uuid.UUID, payload *idtoken.Payload) (*models.User, error) { + name, okName := payload.Claims["name"].(string) + picture, okPicture := payload.Claims["picture"].(string) + sub, okSub := payload.Claims["sub"].(string) + if !okName || !okPicture || !okSub { + return nil, errors.NewUnauthorizedError("invalid google authentication token") + } + + return s.repo.MergeGoogleUser(userID, name, picture, sub) +} diff --git a/internal/services/users/auth/login_test.go b/internal/services/users/auth/login_test.go index a7a2767..ce6c782 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) @@ -94,7 +97,7 @@ func TestLogin_ValidCredentials(t *testing.T) { // TestGoogleLogin_UnSuccessfulType tests GoogleLogin with a non-success token type. func TestGoogleLogin_UnSuccessfulType(t *testing.T) { created := false - user, err := authService.GoogleLogin("test-token", "failure", &created) + user, err := authService.GoogleLogin("test-token", "failure", &created, false) assert.Nil(t, user) assert.NotNil(t, err) @@ -105,7 +108,7 @@ func TestGoogleLogin_UnSuccessfulType(t *testing.T) { // TestGoogleLogin_InvalidToken tests GoogleLogin with an invalid google idToken. func TestGoogleLogin_InvalidToken(t *testing.T) { created := false - user, err := authService.GoogleLogin("invalid-token", "success", &created) + user, err := authService.GoogleLogin("invalid-token", "success", &created, false) assert.Nil(t, user) assert.NotNil(t, err) @@ -118,7 +121,7 @@ func TestGoogleLogin_InvalidToken(t *testing.T) { func TestGoogleLogin_Success(t *testing.T) { // Doesnt exist so create it created := false - user, err := authService.GoogleLogin("valid-token", "success", &created) + user, err := authService.GoogleLogin("valid-token", "success", &created, false) assert.NotNil(t, user) assert.Nil(t, err) assert.True(t, created) @@ -126,13 +129,46 @@ func TestGoogleLogin_Success(t *testing.T) { // Now it should login created = false - user, err = authService.GoogleLogin("valid-token", "success", &created) + user, err = authService.GoogleLogin("valid-token", "success", &created, false) assert.NotNil(t, user) assert.Nil(t, err) assert.False(t, created) assert.Equal(t, "Google User", user.Name) } +func TestGoogleLogin_ConfirmAccountMerge(t *testing.T) { + // Create a user with the same email + verifyGoogleLogin := func(idToken, aud string) (*idtoken.Payload, error) { + return &idtoken.Payload{ + Audience: aud, + Issuer: "accounts.google.com", + Claims: map[string]any{ + "email": "test@email.com", + "name": "Google User", + "sub": "google-sub-id", + "picture": "link-to-google-picture", + }, + }, nil + } + utils.VerifyGoogleToken = verifyGoogleLogin + created := false + user, err := authService.GoogleLogin("valid-token", "success", &created, false) + assert.Nil(t, user) + assert.Error(t, err) + assert.False(t, created) + assert.IsType(t, err, &errors.ConflictError{}) + assert.Contains(t, err.Error(), "email is already linked") + + // Confirm account merge + created = false + user, err = authService.GoogleLogin("valid-token", "success", &created, true) + assert.NotNil(t, user) + assert.NoError(t, err) + assert.False(t, created) + assert.Equal(t, "Google User", user.Name) + utils.VerifyGoogleToken = mockVerifyGoogleLogin // Reset mock +} + /* SESSION VALIDATION */ // TestVerifySession_InvalidToken tests with invalid set to true. @@ -238,6 +274,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..5cb3086 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" @@ -12,7 +13,7 @@ type AuthService interface { Login(email, password string) (*models.User, error) // GoogleLogin handles Google login for users. - GoogleLogin(gToken, gType string, created *bool) (*models.User, error) + GoogleLogin(gToken, gType string, created *bool, confirmAccMerge bool) (*models.User, error) // VerifySession verifies the user's session by checking for // validity and user existence. @@ -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 } diff --git a/migrations/000008_blocks_table.down.sql b/migrations/000008_blocks_table.down.sql new file mode 100644 index 0000000..6049f81 --- /dev/null +++ b/migrations/000008_blocks_table.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS blocks; +DROP INDEX IF EXISTS idx_blocks_user_id; diff --git a/migrations/000008_blocks_table.up.sql b/migrations/000008_blocks_table.up.sql new file mode 100644 index 0000000..aac5231 --- /dev/null +++ b/migrations/000008_blocks_table.up.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS blocks ( + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + blocked_until TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_blocks_user_id ON blocks(user_id); diff --git a/migrations/000009_add_user_login_retries.down.sql b/migrations/000009_add_user_login_retries.down.sql new file mode 100644 index 0000000..ed3aacb --- /dev/null +++ b/migrations/000009_add_user_login_retries.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE users +DROP COLUMN IF EXISTS login_retries; diff --git a/migrations/000009_add_user_login_retries.up.sql b/migrations/000009_add_user_login_retries.up.sql new file mode 100644 index 0000000..420f758 --- /dev/null +++ b/migrations/000009_add_user_login_retries.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE users +ADD COLUMN login_retries INT DEFAULT 0;