Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
450 changes: 450 additions & 0 deletions FIXES_PLAN.md

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ services:
- ./services/api-gateway/.env
environment:
- PORT=8080
- JWT_SECRET=${JWT_SECRET}
- USER_SERVICE_URL=http://user-service:8081
- PRODUCT_SERVICE_URL=http://product-service:8082
- ORDER_SERVICE_URL=http://order-service:8083
Expand Down Expand Up @@ -88,6 +89,8 @@ services:
environment:
- PORT=8082
- DATABASE_URL=postgres://auron:auron_pass@products-db:5433/products_db?sslmode=disable
- REDIS_URL=redis://redis:6379/0
- KAFKA_BROKERS=kafka:29092
depends_on:
products-db:
condition: service_healthy
Expand Down
2 changes: 1 addition & 1 deletion services/api-gateway/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,6 @@ SERVICE_URLS=
# SERVICE_URL_NOTIFICATION=http://localhost:8086

REDIS_URL=redis://localhost:6379/0
JWT_PUBLIC_KEY=/run/secrets/jwt-public-key
JWT_SECRET=your-secret-here # must match JWT_SECRET in user-service
RATE_LIMIT_REQUESTS=100
RATE_LIMIT_WINDOW=1m
4 changes: 2 additions & 2 deletions services/api-gateway/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ type Config struct {
Port string
ServiceURLs map[string]string
RedisURL string
JWTPublicKeyPath string
JWTSecret string
RateLimitRequests int
RateLimitWindow time.Duration
}
Expand Down Expand Up @@ -65,7 +65,7 @@ func Load() *Config {
Port: getEnv("PORT", "8080"),
ServiceURLs: serviceURLs,
RedisURL: getEnv("REDIS_URL", "redis://localhost:6379/0"),
JWTPublicKeyPath: getEnv("JWT_PUBLIC_KEY", "/run/secrets/jwt-public-key"),
JWTSecret: getEnv("JWT_SECRET", ""),
RateLimitRequests: getEnvInt("RATE_LIMIT_REQUESTS", 100),
RateLimitWindow: getEnvDuration("RATE_LIMIT_WINDOW", time.Minute),
}
Expand Down
162 changes: 45 additions & 117 deletions services/api-gateway/middleware/auth.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
package middleware

import (
"crypto/rsa"
"encoding/pem"
"errors"
"fmt"
"io/ioutil"
"net/http"
"strings"

Expand All @@ -14,115 +10,59 @@ import (
)

const (
// AuthorizationHeader is the header name for authorization
AuthorizationHeader = "Authorization"
// UserIDKey is the context key for user ID
UserIDKey = "user_id"
// UserEmailKey is the context key for user email
UserEmailKey = "user_email"
// UserRoleKey is the context key for user role
UserRoleKey = "user_role"
UserIDKey = "user_id"
UserEmailKey = "user_email"
UserRoleKey = "user_role"
)

// Claims represents JWT claims
// Claims represents JWT claims produced by user-service (HS256).
// The user UUID lives in the standard "sub" field (RegisteredClaims.Subject).
type Claims struct {
jwt.RegisteredClaims
UserID string `json:"user_id"`
Email string `json:"email"`
Role string `json:"role"`
Email string `json:"email"`
Role string `json:"role"`
}

// JWTMiddleware handles JWT validation
// JWTMiddleware validates HS256 tokens signed with a shared secret.
type JWTMiddleware struct {
publicKey *rsa.PublicKey
secret []byte
}

// NewJWTMiddleware creates a new JWT middleware
func NewJWTMiddleware(keyPath string) (*JWTMiddleware, error) {
keyData, err := ioutil.ReadFile(keyPath)
if err != nil {
return nil, fmt.Errorf("failed to read JWT public key: %w", err)
}

block, _ := pem.Decode(keyData)
if block == nil || block.Type != "PUBLIC KEY" {
return nil, errors.New("failed to parse PEM block containing public key")
// NewJWTMiddleware creates a JWT middleware from the shared HMAC secret.
func NewJWTMiddleware(secret string) (*JWTMiddleware, error) {
if secret == "" {
return nil, fmt.Errorf("JWT_SECRET must not be empty")
}
return &JWTMiddleware{secret: []byte(secret)}, nil
}

pubKey, err := jwt.ParseRSAPublicKeyFromPEM(block.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse RSA public key: %w", err)
// parseToken validates the token string and returns its claims.
func (j *JWTMiddleware) parseToken(tokenString string) (*Claims, error) {
claims := &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 j.secret, nil
})
if err != nil || !token.Valid {
return nil, fmt.Errorf("invalid or expired token")
}

return &JWTMiddleware{
publicKey: pubKey,
}, nil
return claims, nil
}

// Auth returns an authentication middleware
// Auth returns a middleware that validates the JWT and populates context keys.
// Proceeds even if validation fails — use RequireAuth to block unauthenticated requests.
func (j *JWTMiddleware) Auth() gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader(AuthorizationHeader)
if authHeader == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"success": false,
"error": gin.H{
"code": "UNAUTHORIZED",
"message": "Authorization header is required",
},
})
return
}

// Extract token from "Bearer <token>"
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"success": false,
"error": gin.H{
"code": "UNAUTHORIZED",
"message": "Invalid authorization header format",
},
})
return
}

tokenString := parts[1]

// Parse and validate token
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return j.publicKey, nil
})

if err != nil || !token.Valid {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"success": false,
"error": gin.H{
"code": "UNAUTHORIZED",
"message": "Invalid or expired token",
},
})
return
}

// Set claims in context
c.Set(UserIDKey, claims.UserID)
c.Set(UserEmailKey, claims.Email)
c.Set(UserRoleKey, claims.Role)

c.Next()
}
return j.RequireAuth()
}

// RequireAuth returns a middleware that requires authentication
// RequireAuth returns a middleware that rejects requests without a valid JWT.
func (j *JWTMiddleware) RequireAuth() gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader(AuthorizationHeader)
if authHeader == "" {
tokenString := extractBearerToken(c.GetHeader(AuthorizationHeader))
if tokenString == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"success": false,
"error": gin.H{
Expand All @@ -133,29 +73,8 @@ func (j *JWTMiddleware) RequireAuth() gin.HandlerFunc {
return
}

parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"success": false,
"error": gin.H{
"code": "UNAUTHORIZED",
"message": "Invalid authorization header format",
},
})
return
}

tokenString := parts[1]

claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return j.publicKey, nil
})

if err != nil || !token.Valid {
claims, err := j.parseToken(tokenString)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"success": false,
"error": gin.H{
Expand All @@ -166,7 +85,8 @@ func (j *JWTMiddleware) RequireAuth() gin.HandlerFunc {
return
}

c.Set(UserIDKey, claims.UserID)
// Subject holds the user UUID ("sub" claim set by user-service)
c.Set(UserIDKey, claims.Subject)
c.Set(UserEmailKey, claims.Email)
c.Set(UserRoleKey, claims.Role)

Expand Down Expand Up @@ -207,6 +127,14 @@ func (j *JWTMiddleware) RequireRole(roles ...string) gin.HandlerFunc {
}
}

func extractBearerToken(header string) string {
parts := strings.SplitN(header, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") {
return ""
}
return strings.TrimSpace(parts[1])
}

// GetUserID returns the user ID from the context
func GetUserID(c *gin.Context) string {
if userID, exists := c.Get(UserIDKey); exists {
Expand Down
Loading
Loading