From 8e4a0deec8a6dc060b3ebeeb7938d0168cc9a5c4 Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Sat, 14 Jun 2025 23:52:43 -0700 Subject: [PATCH] (FEAT): Removed the need for sessions, going to use JWT's in cookies. This works very well, just need to determine what routes will be protected and such. For now, a simple system is setup, with more to come. For now, this is a WIP and needs some light work. But auth is almost complete. --- doc/TechnicalSpecification.md | 7 --- go.mod | 1 + internal/app/handlers/auth_handler.go | 16 +++++- internal/app/server/middleware.go | 72 +++++++++++++++++++++++++++ internal/app/server/server.go | 17 ++++++- internal/app/service/auth_service.go | 52 +++++++++++++++---- internal/domain/auth/service.go | 2 +- internal/domain/server/server.go | 7 +++ 8 files changed, 152 insertions(+), 22 deletions(-) diff --git a/doc/TechnicalSpecification.md b/doc/TechnicalSpecification.md index d9e42ff..aa744cc 100644 --- a/doc/TechnicalSpecification.md +++ b/doc/TechnicalSpecification.md @@ -262,13 +262,6 @@ found in **OTHER** section. - [x] GoogleRefreshToken () text - [x] Created (Required) date/time stamp -- [ ] Sessions: Represents a single user-session. - - [ ] ID (PK) Serial - - [ ] UserId (FK: User.Id, Required) Serial - - [ ] Token (Required) text - - [ ] Expiration (Required) date/time stamp - - [ ] Created (Required) date/time stamp - - [ ] Engagements: Represents a single engagement from a single user. - [ ] ID (PK) Serial - [ ] Message () text (Used to store any relevant notes, if needed) diff --git a/go.mod b/go.mod index 2676d96..5ce6e59 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.26.0 // indirect github.com/goccy/go-json v0.10.5 // indirect + github.com/golang-jwt/jwt/v5 v5.2.2 // indirect github.com/gorilla/context v1.1.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/sessions v1.4.0 // indirect diff --git a/internal/app/handlers/auth_handler.go b/internal/app/handlers/auth_handler.go index c41ffd2..f30df23 100644 --- a/internal/app/handlers/auth_handler.go +++ b/internal/app/handlers/auth_handler.go @@ -2,6 +2,7 @@ package handlers import ( "net/http" + "time" "github.com/gin-gonic/gin" domain "github.com/haydenhargreaves/Potion/internal/domain/server" @@ -22,9 +23,20 @@ func GoogleCallback(ctx *gin.Context) { code string = ctx.Query("code") ) - if dbUser, googleUserInfo, err := deps.AuthService.GoogleAuthSuccess(state, code); err != nil { + // TODO: Do something real, not just return data + if jwt, dbUser, googleUserInfo, err := deps.AuthService.GoogleAuthSuccess(state, code); err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } else { - ctx.JSON(http.StatusOK, gin.H{"googleUserInfo": googleUserInfo, "dbUser": dbUser}) + // TODO: Update these values when using a real domain. Maybe an ENV? + ctx.SetCookie( + "jwt_token", + jwt, + int(time.Now().Add(7*24*time.Hour).Sub(time.Now()).Seconds()), + "/", + "localhost", + false, // TODO: True in prod + true, + ) + ctx.JSON(http.StatusOK, gin.H{"jwt": jwt, "googleUserInfo": googleUserInfo, "dbUser": dbUser}) } } diff --git a/internal/app/server/middleware.go b/internal/app/server/middleware.go index e0d6cb3..969f6ca 100644 --- a/internal/app/server/middleware.go +++ b/internal/app/server/middleware.go @@ -1,7 +1,13 @@ package server import ( + "errors" + "fmt" + "log" + "net/http" + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" domain "github.com/haydenhargreaves/Potion/internal/domain/server" ) @@ -13,3 +19,69 @@ func DepedencyInjectionMiddleware(deps *domain.InjectedDependencies) gin.Handler ctx.Next() } } + +func JwtAuthMiddleWare(jwtSecretKey []byte) gin.HandlerFunc { + return func(ctx *gin.Context) { + // NOTE: Option One: From auth header + // + // authHeader := ctx.GetHeader("Authorization") + // + // if authHeader == "" { + // ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization required."}) + // ctx.Abort() + // return + // } + // + // parts := strings.SplitN(authHeader, " ", 2) + // if !(len(parts) == 2 && parts[0] == "Bearer") { + // ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header must be Bearer token"}) + // ctx.Abort() + // return + // } + // + // tokenString := parts[1] + + // TODO: How are we handling faliure? + + tokenString, err := ctx.Cookie("jwt_token") + if err != nil { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "JWT token cookie not found"}) + ctx.Abort() + return + } + + claims := &domain.JwtClaims{} + // token, err := jwt.ParseWithClaims(tokenString, claims, ) + + token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return jwtSecretKey, nil + }) + + if err != nil { + if errors.Is(err, jwt.ErrSignatureInvalid) { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token signature"}) + } else if errors.Is(err, jwt.ErrTokenExpired) || errors.Is(err, jwt.ErrTokenNotValidYet) { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Token expired or not yet valid"}) + } else { + log.Printf("JWT parsing error: %v", err) + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"}) + } + ctx.Abort() + return + } + + if !token.Valid { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"}) + ctx.Abort() + return + } + + // Found: Set the values + ctx.Set("userId", claims.UserId) + ctx.Set("userEmail", claims.Email) + ctx.Next() + } +} diff --git a/internal/app/server/server.go b/internal/app/server/server.go index e6db8d4..da62425 100644 --- a/internal/app/server/server.go +++ b/internal/app/server/server.go @@ -101,17 +101,26 @@ func (s *Server) Start() { } func (s *Server) Setup() *Server { + err := godotenv.Load(".env") + if err != nil { + panic("Could not load env file") + } + + jwtSecret := []byte(os.Getenv("JWT_SECRET")) + // Initialize and inject dependencies userRepo := repository.NewUserRepository(s.DB) userService := service.NewUserService(userRepo) - authService := service.NewAuthService(userRepo) + authService := service.NewAuthService(userRepo, jwtSecret) deps := &domain.InjectedDependencies{ UserService: userService, AuthService: authService, } + // Apply middleware s.Router.Use(DepedencyInjectionMiddleware(deps)) + s.Router.Use(JwtAuthMiddleWare(jwtSecret)) // Wrap all routes with a version router_v1 := s.Router.Group("/v1") @@ -125,6 +134,12 @@ func (s *Server) Setup() *Server { // API router endpoints router_api.GET("/", func(ctx *gin.Context) { ctx.JSON(200, gin.H{"message": "Server is active."}) }) + router_api.GET("/tmp", func(ctx *gin.Context) { + userId := ctx.MustGet("userId").(int) + userEmail := ctx.MustGet("userEmail").(string) + + ctx.JSON(200, gin.H{"id": userId, "email": userEmail}) + }) // WEB router endpoints router_web.GET("/login", handlers.LoginPage) diff --git a/internal/app/service/auth_service.go b/internal/app/service/auth_service.go index d4e88bd..82855d1 100644 --- a/internal/app/service/auth_service.go +++ b/internal/app/service/auth_service.go @@ -3,8 +3,11 @@ package service import ( "context" "fmt" + "time" + "github.com/golang-jwt/jwt/v5" domainAuth "github.com/haydenhargreaves/Potion/internal/domain/auth" + domainServer "github.com/haydenhargreaves/Potion/internal/domain/server" domain "github.com/haydenhargreaves/Potion/internal/domain/user" "github.com/haydenhargreaves/Potion/internal/infrastructure/auth" "golang.org/x/oauth2" @@ -27,6 +30,7 @@ import ( // AuthService implements the domain.AuthService defined in the domain module. type AuthService struct { userRepository domain.UserRepository + jwtSecret []byte } // Compile-time check to ensure the AuthService implements domain.AuthService @@ -34,8 +38,11 @@ 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} +func NewAuthService(userRepository domain.UserRepository, jwtSecret []byte) domainAuth.AuthService { + return &AuthService{ + userRepository: userRepository, + jwtSecret: jwtSecret, + } } // GetGoogleAuthUrl generates a URL which is used to redirect the user to the Google sign in page. @@ -53,42 +60,65 @@ func (s *AuthService) GetGoogleAuthUrl() string { // 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. -func (s *AuthService) GoogleAuthSuccess(state, code string) (domain.User, domain.GoogleUserInfo, error) { +func (s *AuthService) GoogleAuthSuccess(state, code string) (string, domain.User, domain.GoogleUserInfo, error) { // Ensure the state matches, prevents M.I.T.M. attacks if state != "randomstate" { - return domain.User{}, 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.User{}, 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.User{}, domain.GoogleUserInfo{}, err + return "", domain.User{}, domain.GoogleUserInfo{}, err } // 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) + 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 + jwt, err := generateJwt(user.Id, user.Email, s.jwtSecret) + return jwt, *user, googleUserInfo, err } // 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()) + return "", domain.User{}, domain.GoogleUserInfo{}, fmt.Errorf("Repository failed to create user: %s", err.Error()) } - // temporary - return newUser, googleUserInfo, nil + jwt, err := generateJwt(newUser.Id, newUser.Email, s.jwtSecret) + return jwt, newUser, googleUserInfo, err } + +func generateJwt(userId int, email string, jwtSecret []byte) (string, error) { + expiration := time.Now().Add(7 * 24 * time.Hour) + + claims := &domainServer.JwtClaims{ + UserId: userId, + Email: email, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(expiration), + IssuedAt: jwt.NewNumericDate(time.Now()), + Subject: fmt.Sprintf("%d", userId), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err := token.SignedString(jwtSecret) + if err != nil { + return "", fmt.Errorf("Failed to sign jwt: %s", err.Error()) + } + + return tokenString, nil +} diff --git a/internal/domain/auth/service.go b/internal/domain/auth/service.go index 48e011b..936ed7f 100644 --- a/internal/domain/auth/service.go +++ b/internal/domain/auth/service.go @@ -6,5 +6,5 @@ import ( type AuthService interface { GetGoogleAuthUrl() string - GoogleAuthSuccess(state, code string) (domain.User, domain.GoogleUserInfo, error) + GoogleAuthSuccess(state, code string) (string, domain.User, domain.GoogleUserInfo, error) } diff --git a/internal/domain/server/server.go b/internal/domain/server/server.go index 2fcf978..d8d8e3b 100644 --- a/internal/domain/server/server.go +++ b/internal/domain/server/server.go @@ -1,6 +1,7 @@ package domain import ( + "github.com/golang-jwt/jwt/v5" domainAuth "github.com/haydenhargreaves/Potion/internal/domain/auth" domainUser "github.com/haydenhargreaves/Potion/internal/domain/user" ) @@ -9,3 +10,9 @@ type InjectedDependencies struct { UserService domainUser.UserService AuthService domainAuth.AuthService } + +type JwtClaims struct { + UserId int `json:"id"` + Email string `json:"email"` + jwt.RegisteredClaims +}