(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.
This commit is contained in:
Hayden Hargreaves 2025-06-14 23:52:43 -07:00
parent d3d9c8a9e2
commit 8e4a0deec8
8 changed files with 152 additions and 22 deletions

View File

@ -262,13 +262,6 @@ found in **OTHER** section.
- [x] GoogleRefreshToken () text - [x] GoogleRefreshToken () text
- [x] Created (Required) date/time stamp - [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. - [ ] Engagements: Represents a single engagement from a single user.
- [ ] ID (PK) Serial - [ ] ID (PK) Serial
- [ ] Message () text (Used to store any relevant notes, if needed) - [ ] Message () text (Used to store any relevant notes, if needed)

1
go.mod
View File

@ -22,6 +22,7 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.26.0 // indirect github.com/go-playground/validator/v10 v10.26.0 // indirect
github.com/goccy/go-json v0.10.5 // 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/context v1.1.2 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect
github.com/gorilla/sessions v1.4.0 // indirect github.com/gorilla/sessions v1.4.0 // indirect

View File

@ -2,6 +2,7 @@ package handlers
import ( import (
"net/http" "net/http"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
domain "github.com/haydenhargreaves/Potion/internal/domain/server" domain "github.com/haydenhargreaves/Potion/internal/domain/server"
@ -22,9 +23,20 @@ func GoogleCallback(ctx *gin.Context) {
code string = ctx.Query("code") 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()}) ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
} else { } 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})
} }
} }

View File

@ -1,7 +1,13 @@
package server package server
import ( import (
"errors"
"fmt"
"log"
"net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
domain "github.com/haydenhargreaves/Potion/internal/domain/server" domain "github.com/haydenhargreaves/Potion/internal/domain/server"
) )
@ -13,3 +19,69 @@ func DepedencyInjectionMiddleware(deps *domain.InjectedDependencies) gin.Handler
ctx.Next() 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()
}
}

View File

@ -101,17 +101,26 @@ func (s *Server) Start() {
} }
func (s *Server) Setup() *Server { 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 // Initialize and inject dependencies
userRepo := repository.NewUserRepository(s.DB) userRepo := repository.NewUserRepository(s.DB)
userService := service.NewUserService(userRepo) userService := service.NewUserService(userRepo)
authService := service.NewAuthService(userRepo) authService := service.NewAuthService(userRepo, jwtSecret)
deps := &domain.InjectedDependencies{ deps := &domain.InjectedDependencies{
UserService: userService, UserService: userService,
AuthService: authService, AuthService: authService,
} }
// Apply middleware
s.Router.Use(DepedencyInjectionMiddleware(deps)) s.Router.Use(DepedencyInjectionMiddleware(deps))
s.Router.Use(JwtAuthMiddleWare(jwtSecret))
// Wrap all routes with a version // Wrap all routes with a version
router_v1 := s.Router.Group("/v1") router_v1 := s.Router.Group("/v1")
@ -125,6 +134,12 @@ func (s *Server) Setup() *Server {
// API router endpoints // API router endpoints
router_api.GET("/", func(ctx *gin.Context) { ctx.JSON(200, gin.H{"message": "Server is active."}) }) 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 // WEB router endpoints
router_web.GET("/login", handlers.LoginPage) router_web.GET("/login", handlers.LoginPage)

View File

@ -3,8 +3,11 @@ package service
import ( import (
"context" "context"
"fmt" "fmt"
"time"
"github.com/golang-jwt/jwt/v5"
domainAuth "github.com/haydenhargreaves/Potion/internal/domain/auth" domainAuth "github.com/haydenhargreaves/Potion/internal/domain/auth"
domainServer "github.com/haydenhargreaves/Potion/internal/domain/server"
domain "github.com/haydenhargreaves/Potion/internal/domain/user" domain "github.com/haydenhargreaves/Potion/internal/domain/user"
"github.com/haydenhargreaves/Potion/internal/infrastructure/auth" "github.com/haydenhargreaves/Potion/internal/infrastructure/auth"
"golang.org/x/oauth2" "golang.org/x/oauth2"
@ -27,6 +30,7 @@ import (
// AuthService implements the domain.AuthService defined in the domain module. // AuthService implements the domain.AuthService defined in the domain module.
type AuthService struct { type AuthService struct {
userRepository domain.UserRepository userRepository domain.UserRepository
jwtSecret []byte
} }
// Compile-time check to ensure the AuthService implements domain.AuthService // 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 // 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. // requires a user repository which it will use to hit the database when needed.
func NewAuthService(userRepository domain.UserRepository) domainAuth.AuthService { func NewAuthService(userRepository domain.UserRepository, jwtSecret []byte) domainAuth.AuthService {
return &AuthService{userRepository: userRepository} return &AuthService{
userRepository: userRepository,
jwtSecret: jwtSecret,
}
} }
// GetGoogleAuthUrl generates a URL which is used to redirect the user to the Google sign in page. // 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 // 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. // 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 // Ensure the state matches, prevents M.I.T.M. attacks
if state != "randomstate" { 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 // Get access token from Google
token, err := auth.GoogleAuthConfig.Exchange(context.Background(), code) token, err := auth.GoogleAuthConfig.Exchange(context.Background(), code)
if err != nil { 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 // Use the access token to get user data
googleUserInfo, err := auth.GetUserData(token.AccessToken) googleUserInfo, err := auth.GetUserData(token.AccessToken)
if err != nil { 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 // Attempt to get the user, user is nil when they don't exit
user, err := s.userRepository.GetGoogleUser(googleUserInfo.Id) user, err := s.userRepository.GetGoogleUser(googleUserInfo.Id)
if err != nil { 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 // A user was found
if user != nil { 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 // user did not exist, need to create one
newUser, err := s.userRepository.CreateGoogleUser(&googleUserInfo, token.RefreshToken) newUser, err := s.userRepository.CreateGoogleUser(&googleUserInfo, token.RefreshToken)
if err != nil { 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 jwt, err := generateJwt(newUser.Id, newUser.Email, s.jwtSecret)
return newUser, googleUserInfo, nil 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
}

View File

@ -6,5 +6,5 @@ import (
type AuthService interface { type AuthService interface {
GetGoogleAuthUrl() string GetGoogleAuthUrl() string
GoogleAuthSuccess(state, code string) (domain.User, domain.GoogleUserInfo, error) GoogleAuthSuccess(state, code string) (string, domain.User, domain.GoogleUserInfo, error)
} }

View File

@ -1,6 +1,7 @@
package domain package domain
import ( import (
"github.com/golang-jwt/jwt/v5"
domainAuth "github.com/haydenhargreaves/Potion/internal/domain/auth" domainAuth "github.com/haydenhargreaves/Potion/internal/domain/auth"
domainUser "github.com/haydenhargreaves/Potion/internal/domain/user" domainUser "github.com/haydenhargreaves/Potion/internal/domain/user"
) )
@ -9,3 +10,9 @@ type InjectedDependencies struct {
UserService domainUser.UserService UserService domainUser.UserService
AuthService domainAuth.AuthService AuthService domainAuth.AuthService
} }
type JwtClaims struct {
UserId int `json:"id"`
Email string `json:"email"`
jwt.RegisteredClaims
}