From bbe5a8dccfb322d52d69c7571bef998f1a3e1e1d Mon Sep 17 00:00:00 2001 From: maxogod Date: Tue, 10 Jun 2025 03:36:58 -0300 Subject: [PATCH 1/5] wip: google acc merge if already exists --- internal/handlers/users/auth/login.go | 5 ++++- internal/services/users/auth/login.go | 20 +++++++++++++++++++- internal/services/users/auth/login_test.go | 8 ++++---- internal/services/users/auth/service.go | 2 +- 4 files changed, 28 insertions(+), 7 deletions(-) diff --git a/internal/handlers/users/auth/login.go b/internal/handlers/users/auth/login.go index b259eb0..c8340fa 100644 --- a/internal/handlers/users/auth/login.go +++ b/internal/handlers/users/auth/login.go @@ -81,6 +81,7 @@ 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)" @@ -101,8 +102,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/services/users/auth/login.go b/internal/services/users/auth/login.go index acbcce8..dd6c4b7 100644 --- a/internal/services/users/auth/login.go +++ b/internal/services/users/auth/login.go @@ -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") } @@ -80,6 +80,9 @@ func (s *authService) GoogleLogin(gToken, gType string, created *bool) (*models. if blocked, err := s.repo.IsUserBlocked(user.ID); blocked || err != nil { return nil, errors.NewUnauthorizedError("user is blocked or an error occurred") } + if confirmAccMerge { + panic("implement") + } userSub, err := s.repo.GetGoogleSubByUserID(user.ID) if err != nil || userSub != sub { @@ -183,3 +186,18 @@ func (s *authService) googleSignup(payload *idtoken.Payload) (*models.User, erro return user, nil } + +// func mergeGoogleUserData(user *models.User, payload *idtoken.Payload) { +// if name, ok := payload.Claims["name"].(string); ok { +// user.Name = name +// } +// if email, ok := payload.Claims["email"].(string); ok { +// user.Email = email +// } +// if picture, ok := payload.Claims["picture"].(string); ok { +// user.Picture = picture +// } +// if sub, ok := payload.Claims["sub"].(string); ok { +// user.GoogleSub = sub +// } +// } diff --git a/internal/services/users/auth/login_test.go b/internal/services/users/auth/login_test.go index 2279e67..1024449 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,7 +129,7 @@ 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) 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. From a4f1a59b600fd21466544b6239fb92fc59e7bf96 Mon Sep 17 00:00:00 2001 From: maxogod Date: Tue, 10 Jun 2025 04:16:05 -0300 Subject: [PATCH 2/5] feat: impl google acc merge --- internal/repository/memory/users.go | 15 +++++++ internal/repository/users/postgres.go | 39 +++++++++++++++++ internal/repository/users/postgres_test.go | 50 ++++++++++++++++++++++ internal/repository/users/repository.go | 3 ++ internal/services/users/auth/login.go | 32 +++++++------- 5 files changed, 122 insertions(+), 17 deletions(-) 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 dd6c4b7..289e0fc 100644 --- a/internal/services/users/auth/login.go +++ b/internal/services/users/auth/login.go @@ -79,9 +79,11 @@ func (s *authService) GoogleLogin(gToken, gType string, created *bool, confirmAc *created = false if blocked, err := s.repo.IsUserBlocked(user.ID); blocked || err != nil { return nil, errors.NewUnauthorizedError("user is blocked or an error occurred") - } - if confirmAccMerge { - panic("implement") + } else if confirmAccMerge { + user, err = s.mergeGoogleUserData(user.ID, payload) + if err != nil { + return nil, err + } } userSub, err := s.repo.GetGoogleSubByUserID(user.ID) @@ -187,17 +189,13 @@ func (s *authService) googleSignup(payload *idtoken.Payload) (*models.User, erro return user, nil } -// func mergeGoogleUserData(user *models.User, payload *idtoken.Payload) { -// if name, ok := payload.Claims["name"].(string); ok { -// user.Name = name -// } -// if email, ok := payload.Claims["email"].(string); ok { -// user.Email = email -// } -// if picture, ok := payload.Claims["picture"].(string); ok { -// user.Picture = picture -// } -// if sub, ok := payload.Claims["sub"].(string); ok { -// user.GoogleSub = sub -// } -// } +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) +} From efb2776341738b90ec5404ace3f1620c445a6f78 Mon Sep 17 00:00:00 2001 From: maxogod Date: Tue, 10 Jun 2025 21:18:00 -0300 Subject: [PATCH 3/5] tests: Add test for google acc merge --- internal/services/users/auth/login.go | 7 ++++- internal/services/users/auth/login_test.go | 33 ++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/internal/services/users/auth/login.go b/internal/services/users/auth/login.go index 289e0fc..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()) @@ -88,6 +88,11 @@ func (s *authService) GoogleLogin(gToken, gType string, created *bool, confirmAc 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") } diff --git a/internal/services/users/auth/login_test.go b/internal/services/users/auth/login_test.go index 1024449..ce6c782 100644 --- a/internal/services/users/auth/login_test.go +++ b/internal/services/users/auth/login_test.go @@ -136,6 +136,39 @@ func TestGoogleLogin_Success(t *testing.T) { 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. From b1b6db86d0f55dbf22aa0d35c72b23c66cc9de74 Mon Sep 17 00:00:00 2001 From: maxogod Date: Tue, 10 Jun 2025 21:25:37 -0300 Subject: [PATCH 4/5] docs: swagger for google merge --- docs/docs.go | 6 ++++++ docs/swagger.json | 6 ++++++ docs/swagger.yaml | 4 ++++ 3 files changed, 16 insertions(+) diff --git a/docs/docs.go b/docs/docs.go index c00116e..1f29739 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", diff --git a/docs/swagger.json b/docs/swagger.json index dae6127..ea541ff 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", diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 4b99325..df110f3 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 From 4247a4df5f0160ff4ef155c95e6713290fd940d8 Mon Sep 17 00:00:00 2001 From: maxogod Date: Tue, 10 Jun 2025 21:27:48 -0300 Subject: [PATCH 5/5] docs: Add 409 acc existswith email for google login --- docs/docs.go | 6 ++++++ docs/swagger.json | 6 ++++++ docs/swagger.yaml | 5 +++++ internal/handlers/users/auth/login.go | 1 + 4 files changed, 18 insertions(+) diff --git a/docs/docs.go b/docs/docs.go index 1f29739..29a85e6 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -157,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 ea541ff..251e6d1 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -150,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 df110f3..6715694 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -214,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 c8340fa..0a88048 100644 --- a/internal/handlers/users/auth/login.go +++ b/internal/handlers/users/auth/login.go @@ -87,6 +87,7 @@ func (h *authHandler) Login(c *gin.Context) { // @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") {