From b6a434ad2aa8884b8c2595d5bf022ecd0e2aac58 Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Sat, 14 Jun 2025 12:35:08 -0700 Subject: [PATCH] (FEAT): Authentication service with Google OAuth is working! Still missing a UI and we do not have session management just yet, but the workflow of calling the Google API's and creating/finding users is working with the current structure. --- internal/app/handlers/auth_handler.go | 19 +++- internal/app/service/auth_service.go | 62 ++++++---- internal/domain/auth/service.go | 10 ++ internal/domain/server/server.go | 11 ++ internal/domain/user/repository.go | 6 + internal/domain/user/user.go | 14 +++ .../database/postgres/recipe_repository.go | 0 .../database/postgres/user_repository.go | 0 .../database/repository/user_repository.go | 107 ++++++++++++++++++ 9 files changed, 205 insertions(+), 24 deletions(-) create mode 100644 internal/domain/auth/service.go create mode 100644 internal/domain/server/server.go delete mode 100644 internal/infrastructure/database/postgres/recipe_repository.go delete mode 100644 internal/infrastructure/database/postgres/user_repository.go create mode 100644 internal/infrastructure/database/repository/user_repository.go diff --git a/internal/app/handlers/auth_handler.go b/internal/app/handlers/auth_handler.go index b465c93..c41ffd2 100644 --- a/internal/app/handlers/auth_handler.go +++ b/internal/app/handlers/auth_handler.go @@ -4,18 +4,27 @@ import ( "net/http" "github.com/gin-gonic/gin" - "github.com/haydenhargreaves/Potion/internal/app/service" + domain "github.com/haydenhargreaves/Potion/internal/domain/server" ) func GoogleLogin(ctx *gin.Context) { - url := service.GoogleLogin(ctx) + deps := ctx.MustGet("deps").(*domain.InjectedDependencies) + url := deps.AuthService.GetGoogleAuthUrl() + ctx.Redirect(http.StatusSeeOther, url) } func GoogleCallback(ctx *gin.Context) { - if googleUserInfo, err := service.GoogleCallback(ctx); err != nil { - ctx.JSON(http.StatusInternalServerError, err) + deps := ctx.MustGet("deps").(*domain.InjectedDependencies) + + var ( + state string = ctx.Query("state") + code string = ctx.Query("code") + ) + + if dbUser, googleUserInfo, err := deps.AuthService.GoogleAuthSuccess(state, code); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } else { - ctx.JSON(http.StatusOK, googleUserInfo) + ctx.JSON(http.StatusOK, gin.H{"googleUserInfo": googleUserInfo, "dbUser": dbUser}) } } diff --git a/internal/app/service/auth_service.go b/internal/app/service/auth_service.go index 2f0f401..d4e88bd 100644 --- a/internal/app/service/auth_service.go +++ b/internal/app/service/auth_service.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - "github.com/gin-gonic/gin" + domainAuth "github.com/haydenhargreaves/Potion/internal/domain/auth" domain "github.com/haydenhargreaves/Potion/internal/domain/user" "github.com/haydenhargreaves/Potion/internal/infrastructure/auth" "golang.org/x/oauth2" @@ -24,10 +24,24 @@ import ( // // ------------------------------------------------------------------------------------------------ -// GoogleLogin generates a URL which is used to redirect the user to the Google sign in page. This -// URL is in the Google domain and is not coupled with this application. The data from this URL will -// be passed into a callback and work to complete the Google OAuth workflow. -func GoogleLogin(ctx *gin.Context) string { +// AuthService implements the domain.AuthService defined in the domain module. +type AuthService struct { + userRepository domain.UserRepository +} + +// Compile-time check to ensure the AuthService implements domain.AuthService +var _ domainAuth.AuthService = (*AuthService)(nil) + +// NewAuthService creates a user service object which can be passed into the context. The service +// requires a user repository which it will use to hit the database when needed. +func NewAuthService(userRepository domain.UserRepository) domainAuth.AuthService { + return &AuthService{userRepository: userRepository} +} + +// GetGoogleAuthUrl generates a URL which is used to redirect the user to the Google sign in page. +// This URL is in the Google domain and is not coupled with this application. The data from this URL +// will be passed into a callback and work to complete the Google OAuth workflow. +func (s *AuthService) GetGoogleAuthUrl() string { url := auth.GoogleAuthConfig.AuthCodeURL( "randomstate", oauth2.AccessTypeOffline, @@ -37,34 +51,44 @@ func GoogleLogin(ctx *gin.Context) string { return url } -// GoogleCallback accepts the data from the Google login endpoint and uses it to fetch the users +// GoogleAuthSuccess accepts the data from the Google login endpoint and uses it to fetch the users // data. The data is then used to log the user in or create an account. -// -// TODO: The repository implementation is not yet done. -func GoogleCallback(ctx *gin.Context) (domain.GoogleUserInfo, error) { - var ( - state string = ctx.Query("state") - code string = ctx.Query("code") - ) - +func (s *AuthService) GoogleAuthSuccess(state, code string) (domain.User, domain.GoogleUserInfo, error) { // Ensure the state matches, prevents M.I.T.M. attacks if state != "randomstate" { - return domain.GoogleUserInfo{}, fmt.Errorf("States don't match, received %s", state) + return domain.User{}, domain.GoogleUserInfo{}, fmt.Errorf("States don't match, received %s", state) } // Get access token from Google token, err := auth.GoogleAuthConfig.Exchange(context.Background(), code) if err != nil { - return domain.GoogleUserInfo{}, fmt.Errorf("Code exchange failed: %s", err.Error()) + return domain.User{}, domain.GoogleUserInfo{}, fmt.Errorf("Code exchange failed: %s", err.Error()) } + // Use the access token to get user data googleUserInfo, err := auth.GetUserData(token.AccessToken) if err != nil { - return domain.GoogleUserInfo{}, err + return domain.User{}, domain.GoogleUserInfo{}, err } - // TODO: Need to hit the repository to store the required data in the user table + // Attempt to get the user, user is nil when they don't exit + user, err := s.userRepository.GetGoogleUser(googleUserInfo.Id) + if err != nil { + return domain.User{}, domain.GoogleUserInfo{}, fmt.Errorf("Failed to get db user: %s", err) + } + + // A user was found + if user != nil { + return *user, googleUserInfo, nil + } + + // user did not exist, need to create one + newUser, err := s.userRepository.CreateGoogleUser(&googleUserInfo, token.RefreshToken) + if err != nil { + return domain.User{}, domain.GoogleUserInfo{}, fmt.Errorf("Repository failed to create user: %s", err.Error()) + } // temporary - return googleUserInfo, nil + return newUser, googleUserInfo, nil + } diff --git a/internal/domain/auth/service.go b/internal/domain/auth/service.go new file mode 100644 index 0000000..48e011b --- /dev/null +++ b/internal/domain/auth/service.go @@ -0,0 +1,10 @@ +package domain + +import ( + domain "github.com/haydenhargreaves/Potion/internal/domain/user" +) + +type AuthService interface { + GetGoogleAuthUrl() string + GoogleAuthSuccess(state, code string) (domain.User, domain.GoogleUserInfo, error) +} diff --git a/internal/domain/server/server.go b/internal/domain/server/server.go new file mode 100644 index 0000000..2fcf978 --- /dev/null +++ b/internal/domain/server/server.go @@ -0,0 +1,11 @@ +package domain + +import ( + domainAuth "github.com/haydenhargreaves/Potion/internal/domain/auth" + domainUser "github.com/haydenhargreaves/Potion/internal/domain/user" +) + +type InjectedDependencies struct { + UserService domainUser.UserService + AuthService domainAuth.AuthService +} diff --git a/internal/domain/user/repository.go b/internal/domain/user/repository.go index e69de29..42ba36a 100644 --- a/internal/domain/user/repository.go +++ b/internal/domain/user/repository.go @@ -0,0 +1,6 @@ +package domain + +type UserRepository interface { + CreateGoogleUser(googleUserInfo *GoogleUserInfo, googleRefreshToken string) (User, error) + GetGoogleUser(googleId string) (*User, error) +} diff --git a/internal/domain/user/user.go b/internal/domain/user/user.go index 7b53011..ff744a0 100644 --- a/internal/domain/user/user.go +++ b/internal/domain/user/user.go @@ -1,5 +1,7 @@ package domain +import "time" + // GoogleUserInfo is a data type which contains a mapping of the Google User Info API call. type GoogleUserInfo struct { Id string `json:"id"` @@ -10,3 +12,15 @@ type GoogleUserInfo struct { FamilyName string `json:"family_name"` Picture string `json:"picture"` } + +// User is the database model of a user. There is no need to map to a different model so +// this will remain in the domain. +type User struct { + Id int + GoogleId string + Name string + Email string + ImageUrl string + GoogleRefreshToken string + Created time.Time +} diff --git a/internal/infrastructure/database/postgres/recipe_repository.go b/internal/infrastructure/database/postgres/recipe_repository.go deleted file mode 100644 index e69de29..0000000 diff --git a/internal/infrastructure/database/postgres/user_repository.go b/internal/infrastructure/database/postgres/user_repository.go deleted file mode 100644 index e69de29..0000000 diff --git a/internal/infrastructure/database/repository/user_repository.go b/internal/infrastructure/database/repository/user_repository.go new file mode 100644 index 0000000..2634b2c --- /dev/null +++ b/internal/infrastructure/database/repository/user_repository.go @@ -0,0 +1,107 @@ +package repository + +import ( + "database/sql" + + domain "github.com/haydenhargreaves/Potion/internal/domain/user" + _ "github.com/lib/pq" +) + +type UserRepository struct { + db *sql.DB +} + +// Compile-time check to ensure the UserRepository implements domain.UserRepository +var _ domain.UserRepository = (*UserRepository)(nil) + +// NewUserRepository creates a user repository object which is used by the user service to access +// the database. Any user related database operations will take place in this repository. +func NewUserRepository(db *sql.DB) domain.UserRepository { + return &UserRepository{db: db} +} + +// CreateGoogleUser creates a user entry in the users table. The refresh token is required along +// with the Google user info, in order to complete the database schema. Currently, Google login is +// the only support method of authentication, if this changes, this repository may need updates to +// match an updated table schema. +// +// This function will NOT check if the user already exists, if they do, it will return an error. For +// best results, pair this function with the GetGoogleUser which will return the user if it can find +// it. +func (r *UserRepository) CreateGoogleUser(googleUserInfo *domain.GoogleUserInfo, googleRefreshToken string) (domain.User, error) { + tx, err := r.db.Begin() + if err != nil { + tx.Rollback() + return domain.User{}, err + } + + var user domain.User + query := `INSERT INTO users + (GoogleId, Name, Email, ImageUrl, GoogleRefreshToken) + VALUES ($1, $2, $3, $4, $5) RETURNING *;` + + if err := tx.QueryRow( + query, + googleUserInfo.Id, + googleUserInfo.Name, + googleUserInfo.Email, + googleUserInfo.Picture, + googleRefreshToken, + ).Scan( + &user.Id, + &user.GoogleId, + &user.Name, + &user.Email, + &user.ImageUrl, + &user.GoogleRefreshToken, + &user.Created, + ); err != nil { + tx.Rollback() + return domain.User{}, err + } + + if err := tx.Commit(); err != nil { + tx.Rollback() + return domain.User{}, err + } + + return user, nil +} + +// GetGoogleUser attempts to find a user in the database via its Google ID, not the database ID. This +// function is used when a user logs in with Google to prevent duplicate entries from being made. If +// no user is found, this function will return a null pointer but not an error. +func (r *UserRepository) GetGoogleUser(googleId string) (*domain.User, error) { + tx, err := r.db.Begin() + if err != nil { + tx.Rollback() + return nil, err + } + + var user domain.User + query := `SELECT * FROM users WHERE GoogleId = $1` + + if err := tx.QueryRow(query, googleId).Scan( + &user.Id, + &user.GoogleId, + &user.Name, + &user.Email, + &user.ImageUrl, + &user.GoogleRefreshToken, + &user.Created, + ); err != nil { + // If no user was found, don't error, just return + if err == sql.ErrNoRows { + return nil, nil + } + tx.Rollback() + return nil, err + } + + if err := tx.Commit(); err != nil { + tx.Rollback() + return nil, err + } + + return &user, nil +}