diff --git a/.env.example b/.env.example index f969979..76f3e85 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,7 @@ DATABASE_URI= CONFIG_PATH=config/local.yaml + +JWT_PRIVATE_KEY=mykeyhdhd + +CLIENT_URL=http://localhost:5173 diff --git a/go.mod b/go.mod index 7161558..047aa60 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.24.0 // indirect + github.com/golang-jwt/jwt/v5 v5.2.1 // indirect github.com/ilyakaznacheev/cleanenv v1.5.0 // indirect github.com/joho/godotenv v1.5.1 // indirect github.com/leodido/go-urn v1.4.0 // indirect diff --git a/go.sum b/go.sum index 30992cc..9466808 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.24.0 h1:KHQckvo8G6hlWnrPX4NJJ+aBfWNAE/HH+qdL2cBpCmg= github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/ilyakaznacheev/cleanenv v1.5.0 h1:0VNZXggJE2OYdXE87bfSSwGxeiGt9moSR2lOrsHHvr4= github.com/ilyakaznacheev/cleanenv v1.5.0/go.mod h1:a5aDzaJrLCQZsazHol1w8InnDcOX0OColm64SlIi6gk= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= diff --git a/internal/api/middleware/auth_middleware.go b/internal/api/middleware/auth_middleware.go index 7c2adae..f005b09 100644 --- a/internal/api/middleware/auth_middleware.go +++ b/internal/api/middleware/auth_middleware.go @@ -1,26 +1,38 @@ package middleware import ( + "context" "net/http" "github.com/gauravst/go-api-template/internal/config" + "github.com/gauravst/go-api-template/internal/utils/jwtToken" + "github.com/gauravst/go-api-template/internal/utils/response" ) -func Auth(cfg *config.Config, authService *services.AuthService) func(http.Handler) http.Handler { +type contextKey string + +const userDataKey contextKey = "userData" + +func Auth(cfg *config.Config) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Extract the token from the request headers cookie, err := r.Cookie("accessToken") if err != nil { - http.Error(w, "Unauthorized", http.StatusUnauthorized) + response.WriteJson(w, http.StatusUnauthorized, response.GeneralError(err)) return } token := cookie.Value - // refresh token - // logout or set new access token here based on status - // If the token is valid, call the next handler + userData, err := jwtToken.VerifyJwtAndGetData(token, cfg.JwtPrivateKey) + if err != nil { + response.WriteJson(w, http.StatusUnauthorized, response.GeneralError(err)) + return + } + + ctx := context.WithValue(r.Context(), userDataKey, userData) + r.WithContext(ctx) next.ServeHTTP(w, r) }) } diff --git a/internal/api/middleware/cors_middleware.go b/internal/api/middleware/cors_middleware.go new file mode 100644 index 0000000..c6cfde2 --- /dev/null +++ b/internal/api/middleware/cors_middleware.go @@ -0,0 +1,33 @@ +package middleware + +import ( + "net/http" + + "github.com/gauravst/go-api-template/internal/config" +) + +func CORS(cfg *config.Config) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Allow only the specific frontend URL + w.Header().Set("Access-Control-Allow-Origin", cfg.ClientUrl) + + // Allow credentials (IMPORTANT for cookies/tokens) + w.Header().Set("Access-Control-Allow-Credentials", "true") + + // Allowed methods + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + + // Allowed headers + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + + // Handle preflight requests + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + next.ServeHTTP(w, r) + }) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 2c3ecc1..aadd977 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,9 +15,11 @@ type HTTPServer struct { } type Config struct { - Env string `yaml:"env" env-required:"true" env-default:"production` - DatabaseUri string `env:"DATABASE_URI" env-required:"true"` - HTTPServer `yaml:"http_server"` + Env string `yaml:"env" env-required:"true" env-default:"production"` + DatabaseUri string `env:"DATABASE_URI" env-required:"true"` + JwtPrivateKey string `env:"JWT_PRIVATE_KEY" env-required:"true"` + ClientUrl string `env:"CLIENT_URL" env-required:"true"` + HTTPServer `yaml:"http_server"` } func ConfigMustLoad() *Config { diff --git a/internal/utils/email/email.go b/internal/utils/email/email.go new file mode 100644 index 0000000..e540c7c --- /dev/null +++ b/internal/utils/email/email.go @@ -0,0 +1,13 @@ +package email + +import "net/smtp" + +func SendEmail(username, password, host, from, port string, toList []string, body []byte) error { + auth := smtp.PlainAuth("", username, password, host) + err := smtp.SendMail(host+":"+port, auth, from, toList, body) + if err != nil { + return err + } + + return nil +} diff --git a/internal/utils/hashing/hashing.go b/internal/utils/hashing/hashing.go new file mode 100644 index 0000000..1fa58dd --- /dev/null +++ b/internal/utils/hashing/hashing.go @@ -0,0 +1,23 @@ +package hashing + +import ( + "fmt" + + "golang.org/x/crypto/bcrypt" +) + +func GenerateHashString(data string) (string, error) { + hashedValue, err := bcrypt.GenerateFromPassword([]byte(data), bcrypt.DefaultCost) + if err != nil { + return "", nil + } + return string(hashedValue), nil +} + +func CompareHashString(hashedValue string, normalValue string) error { + err := bcrypt.CompareHashAndPassword([]byte(hashedValue), []byte(normalValue)) + if err != nil { + return fmt.Errorf("invalid credentials") + } + return nil +} diff --git a/internal/utils/jwtToken/jwtToken.go b/internal/utils/jwtToken/jwtToken.go new file mode 100644 index 0000000..de34192 --- /dev/null +++ b/internal/utils/jwtToken/jwtToken.go @@ -0,0 +1,69 @@ +package jwtToken + +import ( + "errors" + "fmt" + "net/http" + + "github.com/golang-jwt/jwt/v5" +) + +func VerifyJwtAndGetData(jwtToken string, key string) (map[string]interface{}, error) { + token, err := jwt.Parse(jwtToken, func(token *jwt.Token) (interface{}, error) { + // Ensure the token uses the correct signing method + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + // Convert key (string) to []byte for HS256 + return []byte(key), nil + }) + + // Extract claims regardless of token validity + var claims jwt.MapClaims + if token != nil { + if data, ok := token.Claims.(jwt.MapClaims); ok { + claims = data + } + } + + // Check if there's an error, but still return claims if possible + if err != nil { + // If token expired, return claims with specific error + if errors.Is(err, jwt.ErrSignatureInvalid) { + return nil, fmt.Errorf("invalid token signature") + } + if claims != nil { + return claims, fmt.Errorf("token has expired") + } + return nil, err + } + + return claims, nil +} + +func CreateNewToken(data interface{}, key string) (string, error) { + claims, ok := data.(jwt.MapClaims) + if !ok { + return "", errors.New("invalid claims type") + } + + token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) + tokenString, err := token.SignedString(key) + + if err != nil { + return "", err + } + + return tokenString, nil +} + +func SetAccessToken(w http.ResponseWriter, token string, secure bool) { + http.SetCookie(w, &http.Cookie{ + Name: "accessToken", + Value: token, + Path: "/", + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, + }) +} diff --git a/makefile b/makefile index 8ad1952..fbb406d 100644 --- a/makefile +++ b/makefile @@ -5,6 +5,10 @@ export OLD_IMPORT=github.com/gauravst/go-api-template OLD_CMD_DIR=cmd/go-api-template +# info Variables +PROJECT_NAME=go-api-template +GITHUB_USERNAME=gauravst + # Variables BINARY_NAME=go-api-template GO_FILES=$(shell find . -name '*.go' -not -path './vendor/*') @@ -21,12 +25,12 @@ all: build # Build the application build: @echo "Building the application..." - go build -o bin/$(BINARY_NAME) cmd/go-api-template/main.go + go build -o bin/$(BINARY_NAME) cmd/$(PROJECT_NAME)/main.go # Run the application run: @echo "Running the application..." - go run cmd/go-api-template/main.go + go run cmd/$(PROJECT_NAME)/main.go # Run tests test: @@ -111,6 +115,11 @@ setup: echo "Updating Makefile..."; \ sed -i 's|OLD_IMPORT=$(OLD_IMPORT)|OLD_IMPORT='$$NEW_IMPORT'|g' makefile; \ sed -i 's|OLD_CMD_DIR=$(OLD_CMD_DIR)|OLD_CMD_DIR='$$NEW_CMD_DIR'|g' makefile; \ + sed -i 's|PROJECT_NAME=$(PROJECT_NAME)|PROJECT_NAME='$$LOWER_PROJECT'|g' makefile; \ + sed -i 's|GITHUB_USERNAME=$(GITHUB_USERNAME)|GITHUB_USERNAME='$$LOWER_USERNAME'|g' makefile; \ + sed -i 's|BINARY_NAME=$(BINARY_NAME)|BINARY_NAME='$$LOWER_PROJECT'|g' makefile; \ + sed -i 's|APP_NAME=$(APP_NAME)|APP_NAME='$$LOWER_PROJECT'|g' makefile; \ + sed -i 's|DOCKER_IMAGE_NAME=$(DOCKER_IMAGE_NAME)|DOCKER_IMAGE_NAME='$$LOWER_PROJECT'|g' makefile; \ echo "Setup completed!" # Help (list all targets)