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
12 changes: 12 additions & 0 deletions docs/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
}
}
}
}
Expand Down
12 changes: 12 additions & 0 deletions docs/swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
}
}
}
}
Expand Down
9 changes: 9 additions & 0 deletions docs/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
6 changes: 5 additions & 1 deletion internal/handlers/users/auth/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand All @@ -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
Expand Down
15 changes: 15 additions & 0 deletions internal/repository/memory/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
39 changes: 39 additions & 0 deletions internal/repository/users/postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,45 @@
})
}

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

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

View check run for this annotation

Codecov / codecov/patch

internal/repository/users/postgres.go#L92-L95

Added lines #L92 - L95 were not covered by tests
}

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

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

View check run for this annotation

Codecov / codecov/patch

internal/repository/users/postgres.go#L101-L102

Added lines #L101 - L102 were not covered by tests

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

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

View check run for this annotation

Codecov / codecov/patch

internal/repository/users/postgres.go#L106-L109

Added lines #L106 - L109 were not covered by tests
}

profile.Picture = picture
if err := r.db.Save(&profile).Error; err != nil {
return nil, errors.NewInternalServerError("could not update profile: " + err.Error())
}

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

View check run for this annotation

Codecov / codecov/patch

internal/repository/users/postgres.go#L114-L115

Added lines #L114 - L115 were not covered by tests

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

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

View check run for this annotation

Codecov / codecov/patch

internal/repository/users/postgres.go#L122-L123

Added lines #L122 - L123 were not covered by tests

return &user, nil
}

/* READ OPERATIONS */

func (r *usersRepository) GetUserByID(id uuid.UUID) (*models.User, error) {
Expand Down
50 changes: 50 additions & 0 deletions internal/repository/users/postgres_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions internal/repository/users/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
25 changes: 23 additions & 2 deletions internal/services/users/auth/login.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package auth

import (
stdErrors "errors"
"fmt"
"time"
"users-microservice/config"
Expand Down Expand Up @@ -38,7 +39,6 @@
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())
Expand All @@ -53,7 +53,7 @@

// 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")
}
Expand All @@ -79,10 +79,20 @@
*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
}

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

View check run for this annotation

Codecov / codecov/patch

internal/services/users/auth/login.go#L85-L86

Added lines #L85 - L86 were not covered by tests
}

userSub, err := s.repo.GetGoogleSubByUserID(user.ID)
if err != nil || userSub != sub {
var notFoundErr *errors.NotFoundError
if err != nil && stdErrors.As(err, &notFoundErr) && 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")
}

Expand Down Expand Up @@ -183,3 +193,14 @@

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

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

View check run for this annotation

Codecov / codecov/patch

internal/services/users/auth/login.go#L202-L203

Added lines #L202 - L203 were not covered by tests

return s.repo.MergeGoogleUser(userID, name, picture, sub)
}
41 changes: 37 additions & 4 deletions internal/services/users/auth/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -121,21 +121,54 @@ 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)
assert.Equal(t, "Google User", user.Name)

// 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.
Expand Down
2 changes: 1 addition & 1 deletion internal/services/users/auth/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down