diff --git a/backend/config/config.go b/backend/config/config.go index e2db862..c78ab7a 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -8,18 +8,18 @@ import ( ) type Config struct { - Hostname string `mapstructure:"HOSTNAME"` - Port string `mapstructure:"PORT"` - DatabaseUrl string `mapstructure:"POSTGRES_URL"` - RedisUrl string `mapstructure:"REDIS_URL"` - MinioUrl string `mapstructure:"MINIO_URL"` - JwtAlgorithm string `mapstructure:"JWT_ALGORITHM"` - JwtSecret string `mapstructure:"JWT_SECRET"` - JwtIssuer string `mapstructure:"JWT_ISSUER"` - JwtAudience string `mapstructure:"JWT_AUDIENCE"` - JwtExpAccess string `mapstructure:"JWT_EXP_ACCESS"` - JwtExpRefresh string `mapstructure:"JWT_EXP_REFRESH"` - Environment string `mapstructure:"ENVIRONMENT"` + Hostname string `mapstructure:"HOSTNAME"` + Port string `mapstructure:"PORT"` + DatabaseUrl string `mapstructure:"POSTGRES_URL"` + RedisUrl string `mapstructure:"REDIS_URL"` + MinioUrl string `mapstructure:"MINIO_URL"` + JwtAlgorithm string `mapstructure:"JWT_ALGORITHM"` + JwtSecret string `mapstructure:"JWT_SECRET"` + JwtIssuer string `mapstructure:"JWT_ISSUER"` + JwtAudience string `mapstructure:"JWT_AUDIENCE"` + JwtExpAccess int `mapstructure:"JWT_EXP_ACCESS"` + JwtExpRefresh int `mapstructure:"JWT_EXP_REFRESH"` + Environment string `mapstructure:"ENVIRONMENT"` } func Load() (*Config, error) { @@ -28,8 +28,8 @@ func Load() (*Config, error) { viper.SetDefault("PORT", "8080") viper.SetDefault("JWT_ALGORITHM", "HS256") viper.SetDefault("JWT_SECRET", "default_jwt_secret_please_change") - viper.SetDefault("JWT_EXP_ACCESS", "5m") - viper.SetDefault("JWT_EXP_REFRESH", "1w") + viper.SetDefault("JWT_EXP_ACCESS", "5") + viper.SetDefault("JWT_EXP_REFRESH", "10080") viper.SetDefault("JWT_AUDIENCE", "easywish") viper.SetDefault("JWT_ISSUER", "easywish") viper.SetDefault("ENVIRONMENT", "production") diff --git a/backend/go.mod b/backend/go.mod index 8373f46..7f2369a 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -4,6 +4,7 @@ go 1.24.3 require ( github.com/gin-gonic/gin v1.10.1 + github.com/golang-jwt/jwt/v5 v5.2.2 github.com/jackc/pgx/v5 v5.7.5 github.com/spf13/viper v1.20.1 github.com/swaggo/files v1.0.1 diff --git a/backend/go.sum b/backend/go.sum index 1063166..1223490 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -43,6 +43,8 @@ github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIx github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go new file mode 100644 index 0000000..1cca2b7 --- /dev/null +++ b/backend/internal/middleware/auth.go @@ -0,0 +1,56 @@ +package middleware + +import ( + "easywish/config" + "errors" + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" +) + +type Claims struct { + Username string `json:"username"` + jwt.RegisteredClaims +} + +func JWTAuthMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + cfg := config.GetConfig() + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"}) + return + } + + tokenString := authHeader + + token, err := jwt.ParseWithClaims( + tokenString, + &Claims{}, + func(token *jwt.Token) (any, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return []byte(cfg.JwtSecret), nil + }, + ) + + if err != nil { + if errors.Is(err, jwt.ErrTokenExpired) { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Token expired"}) + } else { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"}) + } + return + } + + if claims, ok := token.Claims.(*Claims); ok && token.Valid { + c.Set("userID", claims.Username) + c.Next() + } else { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid claims"}) + } + } +} diff --git a/backend/internal/routes/setup.go b/backend/internal/routes/setup.go index 4f18533..8fe0445 100644 --- a/backend/internal/routes/setup.go +++ b/backend/internal/routes/setup.go @@ -2,6 +2,7 @@ package routes import ( "easywish/internal/controllers" + "easywish/internal/middleware" "github.com/gin-gonic/gin" ) @@ -24,18 +25,27 @@ func SetupRoutes(r *gin.Engine) *gin.Engine { authGroup.POST("/passwordResetComplete", controllers.PasswordResetComplete) } - accountGroup := apiGroup.Group("/account") - { - accountGroup.PUT("/changePassword", controllers.ChangePassword) - } - profileGroup := apiGroup.Group("/profile") { profileGroup.GET("/:username", controllers.GetProfile) - profileGroup.GET("/me", controllers.GetOwnProfile) - profileGroup.GET("/privacy", controllers.GetPrivacySettings) - profileGroup.PATCH("/privacy", controllers.UpdatePrivacySettings) } + + protected := apiGroup.Group("") + protected.Use(middleware.JWTAuthMiddleware()) + { + accountGroup := protected.Group("/account") + { + accountGroup.PUT("/changePassword", controllers.ChangePassword) + } + + profileGroup := protected.Group("/profile") + { + profileGroup.GET("/me", controllers.GetOwnProfile) + profileGroup.GET("/privacy", controllers.GetPrivacySettings) + profileGroup.PATCH("/privacy", controllers.UpdatePrivacySettings) + } + } + } return r diff --git a/backend/internal/utils/jwt.go b/backend/internal/utils/jwt.go new file mode 100644 index 0000000..51abdaa --- /dev/null +++ b/backend/internal/utils/jwt.go @@ -0,0 +1,26 @@ +package utils + +import ( + "easywish/config" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +func GenerateTokens(username string) (accessToken, refreshToken string, err error) { + cfg := config.GetConfig() + + accessClaims := jwt.MapClaims{ + "username": username, + "exp": time.Now().Add(time.Minute * time.Duration(cfg.JwtExpAccess)).Unix(), + } + accessToken, err = jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims).SignedString([]byte(cfg.JwtSecret)) + + refreshClaims := jwt.MapClaims{ + "username": username, + "exp": time.Now().Add(time.Hour * time.Duration(cfg.JwtExpRefresh)).Unix(), + } + refreshToken, err = jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims).SignedString([]byte(cfg.JwtSecret)) + + return +} diff --git a/docker-compose.yml b/docker-compose.yml index 81fefc5..dd9eec4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,7 @@ services: minio: condition: service_healthy healthcheck: - test: ["CMD", "/usr/bin/curl", "-f", "http://localhost:8080/service/health"] + test: ["CMD", "/usr/bin/curl", "-f", "http://localhost:8080/api/service/health"] interval: 5s timeout: 10s retries: 3