From a9cdc25adfcafa9ccae52db1a5783419fbfb68a0 Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Fri, 13 Jun 2025 22:50:46 -0700 Subject: [PATCH] (FEAT): Implemented the first stages of Google OAuth. All that's left is the UI and repository implementation. --- internal/app/handlers/auth_handler.go | 21 ++++++++ internal/app/server/server.go | 43 +++++++++++++++- internal/app/service/auth_service.go | 70 ++++++++++++++++++++++++++ internal/domain/user/user.go | 12 +++++ internal/infrastructure/auth/google.go | 61 ++++++++++++++++++++++ tailwind.config.js | 5 +- 6 files changed, 210 insertions(+), 2 deletions(-) create mode 100644 internal/app/handlers/auth_handler.go create mode 100644 internal/app/service/auth_service.go create mode 100644 internal/infrastructure/auth/google.go diff --git a/internal/app/handlers/auth_handler.go b/internal/app/handlers/auth_handler.go new file mode 100644 index 0000000..b465c93 --- /dev/null +++ b/internal/app/handlers/auth_handler.go @@ -0,0 +1,21 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/haydenhargreaves/Potion/internal/app/service" +) + +func GoogleLogin(ctx *gin.Context) { + url := service.GoogleLogin(ctx) + ctx.Redirect(http.StatusSeeOther, url) +} + +func GoogleCallback(ctx *gin.Context) { + if googleUserInfo, err := service.GoogleCallback(ctx); err != nil { + ctx.JSON(http.StatusInternalServerError, err) + } else { + ctx.JSON(http.StatusOK, googleUserInfo) + } +} diff --git a/internal/app/server/server.go b/internal/app/server/server.go index 182c0ec..f705e29 100644 --- a/internal/app/server/server.go +++ b/internal/app/server/server.go @@ -2,9 +2,14 @@ package server import ( "fmt" + "os" + "github.com/a-h/templ/examples/integration-gin/gintemplrenderer" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" + "github.com/haydenhargreaves/Potion/internal/app/handlers" + "github.com/haydenhargreaves/Potion/internal/infrastructure/auth" + "github.com/joho/godotenv" ) type Server struct { @@ -25,6 +30,13 @@ func Init(port int) *Server { config: cors.DefaultConfig(), } + // Some stuff for templ rendering + htmlRenderer := server.Router.HTMLRender + server.Router.HTMLRender = &gintemplrenderer.HTMLTemplRenderer{FallbackHtmlRenderer: htmlRenderer} + + // Disable proxy warnings + server.Router.SetTrustedProxies(nil) + // Setup the CORS settings and active them server.config.AllowAllOrigins = true server.Router.Use(cors.New(server.config)) @@ -32,6 +44,28 @@ func Init(port int) *Server { return server } +func (s *Server) ConfigureAuth() *Server { + err := godotenv.Load(".env") + if err != nil { + panic("Could not load env file") + } + + var ( + redirectUrl string = "http://localhost:3000/v1/api/auth/callback" + clientId string = os.Getenv("GOOGLE_CLIENT_ID") + clientSecret string = os.Getenv("GOOGLE_CLIENT_SECRET") + scope []string = []string{ + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + } + ) + + // Setup Google OAuth + auth.NewGoogleConfig(redirectUrl, clientId, clientSecret, scope) + + return s +} + // Start starts the server on the port provided when the server was initialized func (s *Server) Start() { s.Router.Run(fmt.Sprintf(":%d", s.port)) @@ -45,11 +79,18 @@ func (s *Server) Setup() *Server { router_web := router_v1.Group("/web") router_api := router_v1.Group("/api") + // Static routes + router_web.Static("/static", "./web/static") + // API router endpoints router_api.GET("/", func(ctx *gin.Context) { ctx.JSON(200, gin.H{"message": "Server is active."}) }) // WEB router endpoints - router_web.GET("/", nil) + router_web.GET("/login", handlers.LoginPage) + + // Google oauth + router_api.GET("/auth/login", handlers.GoogleLogin) + router_api.GET("/auth/callback", handlers.GoogleCallback) return s } diff --git a/internal/app/service/auth_service.go b/internal/app/service/auth_service.go new file mode 100644 index 0000000..2f0f401 --- /dev/null +++ b/internal/app/service/auth_service.go @@ -0,0 +1,70 @@ +package service + +import ( + "context" + "fmt" + + "github.com/gin-gonic/gin" + domain "github.com/haydenhargreaves/Potion/internal/domain/user" + "github.com/haydenhargreaves/Potion/internal/infrastructure/auth" + "golang.org/x/oauth2" +) + +// NOTE: HOW THIS WORKS +// +// I need to store the refresh token along side the user in the DB. +// Create a "session token" (cookie) with an expiration and store that in the DB and the session. +// Send this session token along with all the requests (stored in the session) and when +// authorization is needed, use it. +// Once the expiration of MY token expires, prompt the user to log back in. +// +// So what is the point of the Google refresh token? Well, its not really useful right now, but if +// we need to perform Google actions, we can use it to get more access tokens, which are needed for +// the Google actions. Currently, I have no need for it, but it will be stored anyway. +// +// ------------------------------------------------------------------------------------------------ + +// 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 { + url := auth.GoogleAuthConfig.AuthCodeURL( + "randomstate", + oauth2.AccessTypeOffline, + oauth2.ApprovalForce, + ) + + return url +} + +// GoogleCallback 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") + ) + + // 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) + } + + // 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()) + } + + googleUserInfo, err := auth.GetUserData(token.AccessToken) + if err != nil { + return domain.GoogleUserInfo{}, err + } + + // TODO: Need to hit the repository to store the required data in the user table + + // temporary + return googleUserInfo, nil +} diff --git a/internal/domain/user/user.go b/internal/domain/user/user.go index e69de29..7b53011 100644 --- a/internal/domain/user/user.go +++ b/internal/domain/user/user.go @@ -0,0 +1,12 @@ +package domain + +// GoogleUserInfo is a data type which contains a mapping of the Google User Info API call. +type GoogleUserInfo struct { + Id string `json:"id"` + Email string `json:"email"` + Verified bool `json:"verified_email"` + Name string `json:"name"` + GivenName string `json:"given_name"` + FamilyName string `json:"family_name"` + Picture string `json:"picture"` +} diff --git a/internal/infrastructure/auth/google.go b/internal/infrastructure/auth/google.go new file mode 100644 index 0000000..83bf51a --- /dev/null +++ b/internal/infrastructure/auth/google.go @@ -0,0 +1,61 @@ +package auth + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + domain "github.com/haydenhargreaves/Potion/internal/domain/user" + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" +) + +// GoogleAuthConfig is a global variable that stores the current state of the Google configuration. +// NewGoogleConfig should be called before this value is accessed, or the application will panic +// to prevent a null reference. +var GoogleAuthConfig oauth2.Config + +// NewGOogleConfig creates a google authentication configuration. The configuration is set as a +// global variable which can be used by importing this module. The configuration is used to query +// the google OAuth API and collect user data. +// +// This function should be called before any calls to the Google API are made, otherwise a null +// value will be accessed and the call will panic. +// +// The global variable is returned by this function but is not needed. +func NewGoogleConfig(redirectUrl, clientId, clientSecret string, scope []string) oauth2.Config { + GoogleAuthConfig = oauth2.Config{ + RedirectURL: redirectUrl, + ClientID: clientId, + ClientSecret: clientSecret, + Scopes: scope, + Endpoint: google.Endpoint, + } + + return GoogleAuthConfig +} + +// GetUserData accepts a access token and calls the Google OAuth API to receive the respective user +// data. Any errors that occur will be wrapped and returned to the caller. +func GetUserData(accessToken string) (domain.GoogleUserInfo, error) { + googleApiUrl := fmt.Sprintf("https://www.googleapis.com/oauth2/v2/userinfo?access_token=%s", accessToken) + + resp, err := http.Get(googleApiUrl) + if err != nil { + return domain.GoogleUserInfo{}, fmt.Errorf("Failed to fetch user info from Google: %s", err.Error()) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return domain.GoogleUserInfo{}, fmt.Errorf("Failed to read body from response: %s", err.Error()) + } + + var googleUserInfo domain.GoogleUserInfo + if err := json.Unmarshal(body, &googleUserInfo); err != nil { + return domain.GoogleUserInfo{}, fmt.Errorf("Failed to parser response: %s", err.Error()) + } + + return googleUserInfo, nil +} diff --git a/tailwind.config.js b/tailwind.config.js index 18764bc..f66e98c 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,5 +1,8 @@ module.exports = { - content: ["./**/*.html", "./**/*.templ", "./**/*.go",], + content: [ + "./internal/templates/**/*.templ", + "./internal/templates/**/*.html", + ], theme: { extend: {}, }, plugins: [], }