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/login.go b/internal/handlers/users/auth/login.go index b259eb0..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 diff --git a/internal/repository/memory/users.go b/internal/repository/memory/users.go index feb12a1..ff25764 100644 --- a/internal/repository/memory/users.go +++ b/internal/repository/memory/users.go @@ -51,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) { diff --git a/internal/repository/users/postgres.go b/internal/repository/users/postgres.go index 489935c..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) { diff --git a/internal/repository/users/postgres_test.go b/internal/repository/users/postgres_test.go index e7829b7..5aa2cd1 100644 --- a/internal/repository/users/postgres_test.go +++ b/internal/repository/users/postgres_test.go @@ -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) { diff --git a/internal/repository/users/repository.go b/internal/repository/users/repository.go index 327f403..17a4559 100644 --- a/internal/repository/users/repository.go +++ b/internal/repository/users/repository.go @@ -17,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) diff --git a/internal/services/users/auth/login.go b/internal/services/users/auth/login.go index acbcce8..767d424 100644 --- a/internal/services/users/auth/login.go +++ b/internal/services/users/auth/login.go @@ -1,6 +1,7 @@ package auth import ( + stdErrors "errors" "fmt" "time" "users-microservice/config" @@ -38,7 +39,6 @@ func (s *authService) Login(email, password string) (*models.User, error) { 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()) @@ -53,7 +53,7 @@ func (s *authService) Login(email, password string) (*models.User, error) { // 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") } @@ -79,10 +79,20 @@ func (s *authService) GoogleLogin(gToken, gType string, created *bool) (*models. *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") } @@ -183,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 2279e67..ce6c782 100644 --- a/internal/services/users/auth/login_test.go +++ b/internal/services/users/auth/login_test.go @@ -97,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) @@ -108,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) @@ -121,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) @@ -129,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. diff --git a/internal/services/users/auth/service.go b/internal/services/users/auth/service.go index 89796db..5cb3086 100644 --- a/internal/services/users/auth/service.go +++ b/internal/services/users/auth/service.go @@ -13,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.