Update besar

This commit is contained in:
meninjar
2025-10-31 02:30:27 +00:00
parent 07d264c57e
commit 0002cf26be
20 changed files with 4939 additions and 1938 deletions
+305
View File
@@ -0,0 +1,305 @@
package middleware
import (
"api-service/internal/config"
"api-service/internal/models/auth"
service "api-service/internal/services/auth"
"api-service/pkg/logger"
"errors"
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/time/rate"
)
var (
ErrInvalidToken = errors.New("invalid token")
ErrTokenExpired = errors.New("token expired")
ErrInvalidSignature = errors.New("invalid token signature")
ErrInvalidIssuer = errors.New("invalid token issuer")
ErrInvalidAudience = errors.New("invalid token audience")
ErrMissingClaims = errors.New("required claims missing")
ErrInvalidAuthHeader = errors.New("invalid authorization header format")
ErrMissingAuthHeader = errors.New("authorization header missing")
)
// TokenCache interface for token caching
type TokenCache interface {
Get(tokenString string) (*auth.JWTClaims, bool)
Set(tokenString string, claims *auth.JWTClaims, expiration time.Duration)
Delete(tokenString string)
}
// InMemoryTokenCache implements TokenCache with in-memory storage
type InMemoryTokenCache struct {
tokens map[string]cacheEntry
mu sync.RWMutex
}
type cacheEntry struct {
claims *auth.JWTClaims
expiration time.Time
}
func NewInMemoryTokenCache() *InMemoryTokenCache {
cache := &InMemoryTokenCache{
tokens: make(map[string]cacheEntry),
}
// Start cleanup goroutine
go cache.cleanup()
return cache
}
func (c *InMemoryTokenCache) Get(tokenString string) (*auth.JWTClaims, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
entry, exists := c.tokens[tokenString]
if !exists || time.Now().After(entry.expiration) {
return nil, false
}
return entry.claims, true
}
func (c *InMemoryTokenCache) Set(tokenString string, claims *auth.JWTClaims, expiration time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.tokens[tokenString] = cacheEntry{
claims: claims,
expiration: time.Now().Add(expiration),
}
}
func (c *InMemoryTokenCache) Delete(tokenString string) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.tokens, tokenString)
}
func (c *InMemoryTokenCache) cleanup() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
c.mu.Lock()
now := time.Now()
for token, entry := range c.tokens {
if now.After(entry.expiration) {
delete(c.tokens, token)
}
}
c.mu.Unlock()
}
}
// AuthMiddleware provides authentication with rate limiting and caching
type AuthMiddleware struct {
providers []AuthProvider
tokenCache TokenCache
rateLimiter *rate.Limiter
config *config.Config
}
func NewAuthMiddleware(
cfg *config.Config,
authService *service.AuthService,
tokenCache TokenCache,
) *AuthMiddleware {
factory := NewProviderFactory(authService, cfg)
providers := factory.CreateProviders()
// Rate limit: 10 requests per second with burst of 20
limiter := rate.NewLimiter(10, 20)
// Use default cache if none provided
if tokenCache == nil {
tokenCache = NewInMemoryTokenCache()
}
return &AuthMiddleware{
providers: providers,
tokenCache: tokenCache,
rateLimiter: limiter,
config: cfg,
}
}
// RequireAuth enforces authentication
func (m *AuthMiddleware) RequireAuth() gin.HandlerFunc {
return m.authenticate(false)
}
// OptionalAuth allows both authenticated and unauthenticated requests
func (m *AuthMiddleware) OptionalAuth() gin.HandlerFunc {
return m.authenticate(true)
}
// authenticate is the core authentication logic
func (m *AuthMiddleware) authenticate(optional bool) gin.HandlerFunc {
return func(c *gin.Context) {
reqLogger := logger.Default().WithService("auth-middleware")
reqLogger.Info("Starting authentication", map[string]interface{}{
"path": c.Request.URL.Path,
"optional": optional,
})
// Apply rate limiting
if !m.rateLimiter.Allow() {
reqLogger.Warn("Rate limit exceeded")
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
"error": "rate limit exceeded",
})
return
}
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
if optional {
c.Next()
return
}
reqLogger.Warn("Authorization header missing")
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": ErrMissingAuthHeader.Error(),
})
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
if optional {
c.Next()
return
}
reqLogger.Warn("Invalid authorization header format")
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": ErrInvalidAuthHeader.Error(),
})
return
}
tokenString := parts[1]
// Check cache first
if claims, found := m.tokenCache.Get(tokenString); found {
reqLogger.Info("Token retrieved from cache", map[string]interface{}{
"user_id": claims.UserID,
})
m.setUserInfo(c, claims, "cache")
c.Next()
return
}
// Try each provider until one succeeds
var validatedClaims *auth.JWTClaims
var err error
var providerName string
var providerErrors []string
for _, provider := range m.providers {
providerLog := reqLogger.WithField("provider", provider.Name())
providerLog.Info("Trying provider")
validatedClaims, err = provider.ValidateToken(tokenString)
if err == nil {
providerName = provider.Name()
providerLog.Info("Authentication successful", map[string]interface{}{
"user_id": validatedClaims.UserID,
})
break
}
providerLog.Warn("Provider validation failed", map[string]interface{}{
"error": err.Error(),
})
providerErrors = append(providerErrors, fmt.Sprintf("provider %s: %v", provider.Name(), err))
}
if err != nil {
if optional {
c.Next()
return
}
reqLogger.Error("All providers failed", map[string]interface{}{
"errors": strings.Join(providerErrors, "; "),
})
// Return specific error message based on the error type
errorMessage := "Token tidak valid"
if errors.Is(err, ErrTokenExpired) {
errorMessage = "Token telah kadaluarsa"
} else if errors.Is(err, ErrInvalidSignature) {
errorMessage = "Signature token tidak valid"
} else if errors.Is(err, ErrInvalidIssuer) {
errorMessage = "Issuer token tidak valid"
} else if errors.Is(err, ErrInvalidAudience) {
errorMessage = "Audience token tidak valid"
}
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": errorMessage,
"details": strings.Join(providerErrors, "; "),
})
return
}
// Cache the validated token
m.tokenCache.Set(tokenString, validatedClaims, 5*time.Minute)
// Set user info in context
m.setUserInfo(c, validatedClaims, providerName)
c.Next()
}
}
// setUserInfo sets user information in the Gin context
func (m *AuthMiddleware) setUserInfo(c *gin.Context, claims *auth.JWTClaims, providerName string) {
c.Set("user_id", claims.UserID)
c.Set("username", claims.Username)
c.Set("email", claims.Email)
c.Set("role", claims.Role)
c.Set("auth_provider", providerName)
}
// RequireRole creates a middleware that requires a specific role
func (m *AuthMiddleware) RequireRole(requiredRole string) gin.HandlerFunc {
return func(c *gin.Context) {
role, exists := c.Get("role")
if !exists {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "user role not found",
})
return
}
userRole, ok := role.(string)
if !ok {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
"error": "invalid role format",
})
return
}
if userRole != requiredRole {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": fmt.Sprintf("requires %s role", requiredRole),
})
return
}
c.Next()
}
}
-59
View File
@@ -1,59 +0,0 @@
package middleware
import (
"fmt"
"net/http"
"api-service/internal/config"
"github.com/gin-gonic/gin"
)
// ConfigurableAuthMiddleware provides flexible authentication based on configuration
func ConfigurableAuthMiddleware(cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
// Skip authentication for development/testing if explicitly disabled
if !cfg.Keycloak.Enabled {
fmt.Println("Authentication is disabled - allowing all requests")
c.Next()
return
}
// Use Keycloak authentication when enabled
AuthMiddleware()(c)
}
}
// StrictAuthMiddleware enforces authentication regardless of Keycloak.Enabled setting
func StrictAuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
if appConfig == nil {
fmt.Println("AuthMiddleware: Config not initialized")
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "authentication service not configured"})
return
}
// Always enforce authentication
AuthMiddleware()(c)
}
}
// OptionalKeycloakAuthMiddleware allows requests but adds authentication info if available
func OptionalKeycloakAuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
if appConfig == nil || !appConfig.Keycloak.Enabled {
c.Next()
return
}
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
// No token provided, but continue
c.Next()
return
}
// Try to validate token, but don't fail if invalid
AuthMiddleware()(c)
}
}
-16
View File
@@ -36,19 +36,3 @@ func ErrorHandler() gin.HandlerFunc {
}
}
}
// CORS middleware configuration
func CORSConfig() gin.HandlerFunc {
return gin.HandlerFunc(func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH")
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
})
}
-254
View File
@@ -1,254 +0,0 @@
package middleware
/** Keycloak Auth Middleware **/
import (
"crypto/rsa"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"math/big"
"net/http"
"strings"
"sync"
"time"
"api-service/internal/config"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/sync/singleflight"
)
var (
ErrInvalidToken = errors.New("invalid token")
)
// JwksCache caches JWKS keys with expiration
type JwksCache struct {
mu sync.RWMutex
keys map[string]*rsa.PublicKey
expiresAt time.Time
sfGroup singleflight.Group
config *config.Config
}
func NewJwksCache(cfg *config.Config) *JwksCache {
return &JwksCache{
keys: make(map[string]*rsa.PublicKey),
config: cfg,
}
}
func (c *JwksCache) GetKey(kid string) (*rsa.PublicKey, error) {
c.mu.RLock()
if key, ok := c.keys[kid]; ok && time.Now().Before(c.expiresAt) {
c.mu.RUnlock()
return key, nil
}
c.mu.RUnlock()
// Fetch keys with singleflight to avoid concurrent fetches
v, err, _ := c.sfGroup.Do("fetch_jwks", func() (interface{}, error) {
return c.fetchKeys()
})
if err != nil {
return nil, err
}
keys := v.(map[string]*rsa.PublicKey)
c.mu.Lock()
c.keys = keys
c.expiresAt = time.Now().Add(1 * time.Hour) // cache for 1 hour
c.mu.Unlock()
key, ok := keys[kid]
if !ok {
return nil, fmt.Errorf("key with kid %s not found", kid)
}
return key, nil
}
func (c *JwksCache) fetchKeys() (map[string]*rsa.PublicKey, error) {
if !c.config.Keycloak.Enabled {
return nil, fmt.Errorf("keycloak authentication is disabled")
}
jwksURL := c.config.Keycloak.JwksURL
if jwksURL == "" {
// Construct JWKS URL from issuer if not explicitly provided
jwksURL = c.config.Keycloak.Issuer + "/protocol/openid-connect/certs"
}
resp, err := http.Get(jwksURL)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var jwksData struct {
Keys []struct {
Kid string `json:"kid"`
Kty string `json:"kty"`
N string `json:"n"`
E string `json:"e"`
} `json:"keys"`
}
if err := json.NewDecoder(resp.Body).Decode(&jwksData); err != nil {
return nil, err
}
keys := make(map[string]*rsa.PublicKey)
for _, key := range jwksData.Keys {
if key.Kty != "RSA" {
continue
}
pubKey, err := parseRSAPublicKey(key.N, key.E)
if err != nil {
continue
}
keys[key.Kid] = pubKey
}
return keys, nil
}
// parseRSAPublicKey parses RSA public key components from base64url strings
func parseRSAPublicKey(nStr, eStr string) (*rsa.PublicKey, error) {
nBytes, err := base64UrlDecode(nStr)
if err != nil {
return nil, err
}
eBytes, err := base64UrlDecode(eStr)
if err != nil {
return nil, err
}
var eInt int
for _, b := range eBytes {
eInt = eInt<<8 + int(b)
}
pubKey := &rsa.PublicKey{
N: new(big.Int).SetBytes(nBytes),
E: eInt,
}
return pubKey, nil
}
func base64UrlDecode(s string) ([]byte, error) {
// Add padding if missing
if m := len(s) % 4; m != 0 {
s += strings.Repeat("=", 4-m)
}
return base64.URLEncoding.DecodeString(s)
}
// Global config instance
var appConfig *config.Config
var jwksCacheInstance *JwksCache
// InitializeAuth initializes the auth middleware with config
func InitializeAuth(cfg *config.Config) {
appConfig = cfg
jwksCacheInstance = NewJwksCache(cfg)
}
// AuthMiddleware validates Bearer token as Keycloak JWT token
func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
if appConfig == nil {
fmt.Println("AuthMiddleware: Config not initialized")
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "authentication service not configured"})
return
}
if !appConfig.Keycloak.Enabled {
// Skip authentication if Keycloak is disabled but log for debugging
fmt.Println("AuthMiddleware: Keycloak authentication is disabled - allowing all requests")
c.Next()
return
}
fmt.Println("AuthMiddleware: Checking Authorization header") // Debug log
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
fmt.Println("AuthMiddleware: Authorization header missing") // Debug log
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header missing"})
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
fmt.Println("AuthMiddleware: Invalid Authorization header format") // Debug log
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header format must be Bearer {token}"})
return
}
tokenString := parts[1]
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// Verify signing method
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
fmt.Printf("AuthMiddleware: Unexpected signing method: %v\n", token.Header["alg"]) // Debug log
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
kid, ok := token.Header["kid"].(string)
if !ok {
fmt.Println("AuthMiddleware: kid header not found") // Debug log
return nil, errors.New("kid header not found")
}
return jwksCacheInstance.GetKey(kid)
}, jwt.WithIssuer(appConfig.Keycloak.Issuer), jwt.WithAudience(appConfig.Keycloak.Audience))
if err != nil || !token.Valid {
fmt.Printf("AuthMiddleware: Invalid or expired token: %v\n", err) // Debug log
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"})
return
}
fmt.Println("AuthMiddleware: Token valid, proceeding") // Debug log
// Token is valid, proceed
c.Next()
}
}
/** JWT Bearer authentication middleware */
// import (
// "net/http"
// "strings"
// "github.com/gin-gonic/gin"
// )
// AuthMiddleware validates Bearer token in Authorization header
func AuthJWTMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header missing"})
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header format must be Bearer {token}"})
return
}
token := parts[1]
// For now, use a static token for validation. Replace with your logic.
const validToken = "your-static-token"
if token != validToken {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
return
}
c.Next()
}
}
+615
View File
@@ -0,0 +1,615 @@
package middleware
import (
"api-service/internal/config"
"api-service/internal/models/auth"
models "api-service/internal/models/auth"
service "api-service/internal/services/auth"
"api-service/pkg/logger"
"crypto/rsa"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"math/big"
"net/http"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/sync/singleflight"
)
// AuthProvider interface for different authentication methods
type AuthProvider interface {
ValidateToken(tokenString string) (*models.JWTClaims, error)
Name() string
}
// ProviderFactory creates authentication providers based on configuration
type ProviderFactory struct {
authService *service.AuthService
config *config.Config
}
func NewProviderFactory(authService *service.AuthService, config *config.Config) *ProviderFactory {
return &ProviderFactory{
authService: authService,
config: config,
}
}
func (f *ProviderFactory) CreateProviders() []AuthProvider {
var providers []AuthProvider
reqLogger := logger.Default().WithService("provider-factory")
reqLogger.Info("Creating authentication providers", map[string]interface{}{
"auth_type": f.config.Auth.Type,
"keycloak_enabled": f.config.Keycloak.Enabled,
"keycloak_issuer": f.config.Keycloak.Issuer,
"static_tokens_len": len(f.config.Auth.StaticTokens),
"fallback_to": f.config.Auth.FallbackTo,
})
switch f.config.Auth.Type {
case "static":
reqLogger.Info("Configuring static token provider")
if len(f.config.Auth.StaticTokens) > 0 {
providers = append(providers, NewStaticTokenProvider(f.config.Auth.StaticTokens))
reqLogger.Info("Static token provider added", map[string]interface{}{
"token_count": len(f.config.Auth.StaticTokens),
})
} else {
reqLogger.Warn("No static tokens configured for static auth type")
}
case "jwt":
reqLogger.Info("Configuring JWT provider")
providers = append(providers, NewJWTAuthProvider(f.authService))
reqLogger.Info("JWT provider added")
case "keycloak":
reqLogger.Info("Configuring Keycloak provider")
if f.config.Keycloak.Issuer != "" {
providers = append(providers, NewKeycloakAuthProvider(f.config))
reqLogger.Info("Keycloak provider added")
} else {
reqLogger.Warn("Keycloak issuer not configured for keycloak auth type")
}
case "hybrid":
reqLogger.Info("Configuring hybrid providers")
if f.config.Keycloak.Issuer != "" {
providers = append(providers, NewKeycloakAuthProvider(f.config))
reqLogger.Info("Keycloak provider added for hybrid")
} else {
reqLogger.Warn("Keycloak issuer not configured for hybrid auth type")
}
switch f.config.Auth.FallbackTo {
case "static":
reqLogger.Info("Configuring static fallback for hybrid")
if len(f.config.Auth.StaticTokens) > 0 {
providers = append(providers, NewStaticTokenProvider(f.config.Auth.StaticTokens))
reqLogger.Info("Static fallback provider added", map[string]interface{}{
"token_count": len(f.config.Auth.StaticTokens),
})
} else {
reqLogger.Warn("No static tokens configured for hybrid fallback")
}
case "jwt":
reqLogger.Info("Configuring JWT fallback for hybrid")
providers = append(providers, NewJWTAuthProvider(f.authService))
reqLogger.Info("JWT fallback provider added")
case "keycloak":
reqLogger.Info("Configuring Keycloak fallback for hybrid")
if f.config.Keycloak.Issuer != "" {
providers = append(providers, NewKeycloakAuthProvider(f.config))
reqLogger.Info("Keycloak fallback provider added")
} else {
reqLogger.Warn("Keycloak issuer not configured for hybrid fallback")
}
default:
reqLogger.Warn("Unknown fallback type for hybrid, using JWT", map[string]interface{}{
"fallback_to": f.config.Auth.FallbackTo,
})
providers = append(providers, NewJWTAuthProvider(f.authService))
reqLogger.Info("JWT fallback provider added as default")
}
default:
reqLogger.Warn("Unknown auth type, defaulting to JWT", map[string]interface{}{
"auth_type": f.config.Auth.Type,
})
providers = append(providers, NewJWTAuthProvider(f.authService))
reqLogger.Info("JWT provider added as default")
}
reqLogger.Info("Provider creation completed", map[string]interface{}{
"provider_count": len(providers),
})
return providers
}
// StaticTokenProvider handles static token authentication
type StaticTokenProvider struct {
tokens map[string]bool
}
func NewStaticTokenProvider(tokens []string) *StaticTokenProvider {
tokenMap := make(map[string]bool)
for _, token := range tokens {
if token != "" {
tokenMap[token] = true
}
}
return &StaticTokenProvider{tokens: tokenMap}
}
func (s *StaticTokenProvider) ValidateToken(tokenString string) (*models.JWTClaims, error) {
reqLogger := logger.Default().WithService("static-auth")
if !s.tokens[tokenString] {
reqLogger.Warn("Invalid static token provided")
return nil, ErrInvalidToken
}
reqLogger.Info("Static token validation successful")
return &models.JWTClaims{
UserID: "static-user",
Username: "static-user",
Email: "[email protected]",
Role: "user",
}, nil
}
func (s *StaticTokenProvider) Name() string {
return "static"
}
// JWTAuthProvider handles JWT authentication using AuthService
type JWTAuthProvider struct {
authService *service.AuthService
}
func NewJWTAuthProvider(authService *service.AuthService) *JWTAuthProvider {
return &JWTAuthProvider{authService: authService}
}
func (j *JWTAuthProvider) ValidateToken(tokenString string) (*models.JWTClaims, error) {
reqLogger := logger.Default().WithService("jwt-auth")
reqLogger.Info("Starting JWT token validation")
claims, err := j.authService.ValidateToken(tokenString)
if err != nil {
reqLogger.Error("JWT validation failed", map[string]interface{}{
"error": err.Error(),
})
return nil, err
}
reqLogger.Info("JWT validation successful", map[string]interface{}{
"user_id": claims.UserID,
})
return claims, nil
}
func (j *JWTAuthProvider) Name() string {
return "jwt"
}
// KeycloakAuthProvider handles Keycloak JWT authentication
type KeycloakAuthProvider struct {
jwksCache *JwksCache
config *config.Config
}
func NewKeycloakAuthProvider(cfg *config.Config) *KeycloakAuthProvider {
return &KeycloakAuthProvider{
jwksCache: NewJwksCache(cfg),
config: cfg,
}
}
func (k *KeycloakAuthProvider) ValidateToken(tokenString string) (*auth.JWTClaims, error) {
reqLogger := logger.Default().WithService("keycloak-auth")
reqLogger.Info("Starting Keycloak token validation")
// Parse token without verification first to get claims for logging
parsedToken, _, err := jwt.NewParser().ParseUnverified(tokenString, jwt.MapClaims{})
if err != nil {
reqLogger.Error("Failed to parse token", map[string]interface{}{
"error": err.Error(),
})
return nil, ErrInvalidToken
}
// Extract claims for logging
claims, ok := parsedToken.Claims.(jwt.MapClaims)
if !ok {
reqLogger.Error("Invalid claims format")
return nil, ErrMissingClaims
}
// Check if token is expired
if exp, ok := claims["exp"].(float64); ok {
if time.Now().Unix() > int64(exp) {
reqLogger.Warn("Token expired", map[string]interface{}{
"exp": exp,
"now": time.Now().Unix(),
})
return nil, ErrTokenExpired
}
}
// Now parse with verification
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// Verify signing method
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
reqLogger.Warn("Unexpected signing method", map[string]interface{}{
"alg": token.Header["alg"],
})
return nil, ErrInvalidSignature
}
kid, ok := token.Header["kid"].(string)
if !ok {
reqLogger.Warn("kid header not found in token")
return nil, errors.New("kid header not found")
}
reqLogger.Info("Looking for key", map[string]interface{}{
"kid": kid,
})
key, err := k.jwksCache.GetKey(kid)
if err != nil {
reqLogger.Error("Failed to get key", map[string]interface{}{
"kid": kid,
"error": err.Error(),
})
return nil, err
}
reqLogger.Info("Key retrieved successfully", map[string]interface{}{
"kid": kid,
})
return key, nil
}, jwt.WithIssuer(k.config.Keycloak.Issuer), jwt.WithAudience(k.config.Keycloak.Audience))
if err != nil {
reqLogger.Error("JWT parse error", map[string]interface{}{
"error": err.Error(),
})
// Return specific error based on the error type
if strings.Contains(err.Error(), "expired") {
return nil, ErrTokenExpired
} else if strings.Contains(err.Error(), "signature") {
return nil, ErrInvalidSignature
} else if strings.Contains(err.Error(), "issuer") {
return nil, ErrInvalidIssuer
} else if strings.Contains(err.Error(), "audience") {
return nil, ErrInvalidAudience
}
return nil, fmt.Errorf("invalid token: %v", err)
}
if !token.Valid {
reqLogger.Warn("Token is not valid")
return nil, ErrInvalidToken
}
reqLogger.Info("Token validation successful")
// Extract claims
claims, ok = token.Claims.(jwt.MapClaims)
if !ok {
reqLogger.Error("Invalid claims format")
return nil, ErrMissingClaims
}
// Validate required claims
userID := getClaimString(claims, "sub")
if userID == "" {
reqLogger.Error("Missing required claim: sub")
return nil, ErrMissingClaims
}
return &auth.JWTClaims{
UserID: userID,
Username: getClaimString(claims, "preferred_username"),
Email: getClaimString(claims, "email"),
Role: getClaimString(claims, "role"),
}, nil
}
func (k *KeycloakAuthProvider) Name() string {
return "keycloak"
}
// UnifiedAuthMiddleware provides flexible authentication based on configuration
func UnifiedAuthMiddleware(cfg *config.Config, authService *service.AuthService) gin.HandlerFunc {
factory := NewProviderFactory(authService, cfg)
providers := factory.CreateProviders()
// Validate that we have at least one provider
if len(providers) == 0 {
logger.Default().Error("No authentication providers configured", map[string]interface{}{
"auth_type": cfg.Auth.Type,
})
return func(c *gin.Context) {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "authentication service not configured"})
}
}
logger.Default().Info("UnifiedAuthMiddleware initialized", map[string]interface{}{
"provider_count": len(providers),
"auth_type": cfg.Auth.Type,
})
return func(c *gin.Context) {
reqLogger := logger.Default().WithService("unified-auth")
reqLogger.Info("Memulai proses autentikasi", map[string]interface{}{
"auth_type": cfg.Auth.Type,
"path": c.Request.URL.Path,
"method": c.Request.Method,
})
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
reqLogger.Warn("Header Authorization tidak ditemukan", map[string]interface{}{
"path": c.Request.URL.Path,
"method": c.Request.Method,
})
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": ErrMissingAuthHeader.Error()})
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
reqLogger.Warn("Format header Authorization tidak valid", map[string]interface{}{
"header_value": authHeader[:min(20, len(authHeader))], // Log first 20 chars for debugging
"path": c.Request.URL.Path,
"method": c.Request.Method,
})
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": ErrInvalidAuthHeader.Error()})
return
}
tokenString := parts[1]
reqLogger.Info("Token diterima", map[string]interface{}{
"token_length": len(tokenString),
"path": c.Request.URL.Path,
"method": c.Request.Method,
})
// Coba setiap provider sampai salah satu berhasil
var claims *auth.JWTClaims
var err error
var providerName string
var providerErrors []string
var triedProviders []string
reqLogger.Info("Starting provider validation loop", map[string]interface{}{
"provider_count": len(providers),
})
for _, provider := range providers {
providerLog := reqLogger.WithField("provider", provider.Name())
triedProviders = append(triedProviders, provider.Name())
providerLog.Info("Mencoba validasi dengan provider", map[string]interface{}{
"path": c.Request.URL.Path,
"method": c.Request.Method,
})
claims, err = provider.ValidateToken(tokenString)
if err == nil {
providerName = provider.Name()
providerLog.Info("Autentikasi berhasil", map[string]interface{}{
"user_id": claims.UserID,
"username": claims.Username,
"role": claims.Role,
"path": c.Request.URL.Path,
"method": c.Request.Method,
})
break // Berhenti jika ada yang berhasil
}
providerLog.Warn("Validasi provider gagal", map[string]interface{}{
"error": err.Error(),
"path": c.Request.URL.Path,
"method": c.Request.Method,
})
providerErrors = append(providerErrors, fmt.Sprintf("provider %s: %v", provider.Name(), err))
}
if err != nil {
reqLogger.Error("Semua provider gagal memvalidasi token", map[string]interface{}{
"errors": strings.Join(providerErrors, "; "),
"tried_providers": strings.Join(triedProviders, ", "),
"path": c.Request.URL.Path,
"method": c.Request.Method,
})
// Return specific error message based on the error type
errorMessage := "Token tidak valid"
if errors.Is(err, ErrTokenExpired) {
errorMessage = "Token telah kadaluarsa"
} else if errors.Is(err, ErrInvalidSignature) {
errorMessage = "Signature token tidak valid"
} else if errors.Is(err, ErrInvalidIssuer) {
errorMessage = "Issuer token tidak valid"
} else if errors.Is(err, ErrInvalidAudience) {
errorMessage = "Audience token tidak valid"
}
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": errorMessage,
"details": strings.Join(providerErrors, "; "),
})
return
}
// Set informasi pengguna di konteks
if claims != nil {
c.Set("user_id", claims.UserID)
c.Set("username", claims.Username)
c.Set("email", claims.Email)
c.Set("role", claims.Role)
c.Set("auth_provider", providerName)
reqLogger.Info("User context set successfully", map[string]interface{}{
"user_id": claims.UserID,
"username": claims.Username,
"role": claims.Role,
"auth_provider": providerName,
"path": c.Request.URL.Path,
"method": c.Request.Method,
})
} else {
reqLogger.Warn("Claims is nil after successful authentication", map[string]interface{}{
"provider": providerName,
"path": c.Request.URL.Path,
"method": c.Request.Method,
})
}
reqLogger.Info("Authentication completed successfully, proceeding to next handler", map[string]interface{}{
"path": c.Request.URL.Path,
"method": c.Request.Method,
})
c.Next()
}
}
// InitializeAuth initializes authentication configuration
func InitializeAuth(cfg *config.Config) {
// This function can be used to initialize global auth settings if needed
logger.Default().Info("Authentication initialized", map[string]interface{}{
"auth_type": cfg.Auth.Type,
})
}
// Helper functions
func getClaimString(claims jwt.MapClaims, key string) string {
if value, ok := claims[key]; ok && value != nil {
if str, ok := value.(string); ok {
return str
}
}
return ""
}
// JwksCache and related functions
type JwksCache struct {
mu sync.RWMutex
keys map[string]*rsa.PublicKey
expiresAt time.Time
sfGroup singleflight.Group
config *config.Config
}
func NewJwksCache(cfg *config.Config) *JwksCache {
return &JwksCache{
keys: make(map[string]*rsa.PublicKey),
config: cfg,
}
}
func (c *JwksCache) GetKey(kid string) (*rsa.PublicKey, error) {
c.mu.RLock()
if key, ok := c.keys[kid]; ok && time.Now().Before(c.expiresAt) {
c.mu.RUnlock()
return key, nil
}
c.mu.RUnlock()
// Fetch keys with singleflight to avoid concurrent fetches
v, err, _ := c.sfGroup.Do("fetch_jwks", func() (interface{}, error) {
return c.fetchKeys()
})
if err != nil {
return nil, err
}
keys := v.(map[string]*rsa.PublicKey)
c.mu.Lock()
c.keys = keys
c.expiresAt = time.Now().Add(1 * time.Hour) // cache for 1 hour
c.mu.Unlock()
key, ok := keys[kid]
if !ok {
return nil, fmt.Errorf("key with kid %s not found", kid)
}
return key, nil
}
func (c *JwksCache) fetchKeys() (map[string]*rsa.PublicKey, error) {
if c.config.Keycloak.Issuer == "" {
return nil, fmt.Errorf("keycloak issuer is not configured")
}
jwksURL := c.config.Keycloak.JwksURL
if jwksURL == "" {
// Construct JWKS URL from issuer if not explicitly provided
jwksURL = c.config.Keycloak.Issuer + "/protocol/openid-connect/certs"
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(jwksURL)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to fetch JWKS: HTTP %d", resp.StatusCode)
}
var jwksData struct {
Keys []struct {
Kid string `json:"kid"`
Kty string `json:"kty"`
N string `json:"n"`
E string `json:"e"`
} `json:"keys"`
}
if err := json.NewDecoder(resp.Body).Decode(&jwksData); err != nil {
return nil, err
}
keys := make(map[string]*rsa.PublicKey)
for _, key := range jwksData.Keys {
if key.Kty != "RSA" {
continue
}
pubKey, err := parseRSAPublicKey(key.N, key.E)
if err != nil {
continue
}
keys[key.Kid] = pubKey
}
return keys, nil
}
// parseRSAPublicKey parses RSA public key components from base64url strings
func parseRSAPublicKey(nStr, eStr string) (*rsa.PublicKey, error) {
nBytes, err := base64.RawURLEncoding.DecodeString(nStr)
if err != nil {
return nil, err
}
eBytes, err := base64.RawURLEncoding.DecodeString(eStr)
if err != nil {
return nil, err
}
n := new(big.Int).SetBytes(nBytes)
e := int(new(big.Int).SetBytes(eBytes).Int64())
return &rsa.PublicKey{
N: n,
E: e,
}, nil
}
+316
View File
@@ -0,0 +1,316 @@
// middleware/security.go
package middleware
import (
"api-service/internal/config"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"github.com/go-redis/redis_rate/v10" // Tambahkan library ini: go get github.com/go-redis/redis_rate/v10
"github.com/redis/go-redis/v9"
)
// Config menyimpan konfigurasi untuk middleware keamanan
type Config struct {
// CORS
TrustedOrigins []string
// Rate Limiting
RedisClient *redis.Client
RequestsPerMin int
// Input Validation
MaxInputLength int
}
// SwaggerSecurityHeaders adalah middleware khusus untuk route dokumentasi.
// CSP-nya dilonggarkan untuk mengizinkan skrip dan gaya inline yang dibutuhkan Swagger UI.
func SwaggerSecurityHeaders() gin.HandlerFunc {
return func(c *gin.Context) {
// Header lainnya tetap bisa diterapkan
c.Header("X-Frame-Options", "DENY")
c.Header("X-Content-Type-Options", "nosniff")
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
c.Header("Permissions-Policy", "geolocation=(), microphone=(), camera=(), payment=(), usb=()")
// CSP yang lebih longgar untuk Swagger UI
// 'unsafe-inline' dibutuhkan untuk skrip dan gaya yang ada di dalam HTML
// data: dibutuhkan jika ada gambar atau resource yang di-encode base64
cspHeader := "default-src 'self'; " +
"script-src 'self' 'unsafe-inline'; " + // <--- PERUBAHAN UTAMA
"style-src 'self' 'unsafe-inline'; " + // <--- Juga sering dibutuhkan
"img-src 'self' data:; " + // <--- Untuk gambar base64
"object-src 'none'; " +
"base-uri 'self'; " +
"frame-ancestors 'none';"
c.Header("Content-Security-Policy", cspHeader)
// HSTS juga bisa diterapkan jika menggunakan HTTPS
if c.Request.TLS != nil {
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
}
c.Next()
}
}
// SecurityHeaders menambahkan header keamanan standar ke semua respons
func SecurityHeaders() gin.HandlerFunc {
return func(c *gin.Context) {
// Mencegah clickjacking
c.Header("X-Frame-Options", "DENY")
// Mencegah MIME type sniffing
c.Header("X-Content-Type-Options", "nosniff")
// Mengaktifkan proteksi XSS (sudah usang di browser modern tapi tetap baik)
c.Header("X-XSS-Protection", "1; mode=block")
// Kebijakan referrer
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
// Kebijakan Keamanan Konten (CSP) - Lebih ketat
// Hindari 'unsafe-inline' di produksi. Gunakan nonce atau hash jika memungkinkan.
c.Header("Content-Security-Policy", "default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none';")
// Kebijakan Izin (Permissions Policy) - Menonaktifkan fitur browser yang tidak dibutuhkan
c.Header("Permissions-Policy", "geolocation=(), microphone=(), camera=(), payment=(), usb=()")
// HSTS (HTTP Strict Transport Security) - Hanya untuk HTTPS
if c.Request.TLS != nil {
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
}
c.Next()
}
}
// SecureCORSConfig menyediakan konfigurasi CORS yang aman dan fleksibel
func SecureCORSConfig(cfg config.SecurityConfig) gin.HandlerFunc {
return cors.New(cors.Config{
AllowOrigins: cfg.TrustedOrigins,
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Length", "Content-Type", "Authorization"},
ExposeHeaders: []string{"Content-Length"},
AllowCredentials: true, // Hanya gunakan 'true' jika Anda benar-benar membutuhkannya (cookie, auth)
MaxAge: 12 * time.Hour,
})
}
// RateLimitByIPRedis membatasi permintaan per IP menggunakan Redis untuk skalabilitas
func RateLimitByIPRedis(cfg config.SecurityConfig) gin.HandlerFunc {
// Buat koneksi Redis dari konfigurasi
rdb := redis.NewClient(&redis.Options{
Addr: fmt.Sprintf("%s:%d", cfg.RateLimit.Redis.Host, cfg.RateLimit.Redis.Port),
Password: cfg.RateLimit.Redis.Password,
DB: cfg.RateLimit.Redis.DB,
})
// Cek koneksi ke Redis
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if _, err := rdb.Ping(ctx).Result(); err != nil {
// Jika gagal konek, gunakan fallback di memori dan log error
fmt.Printf("WARNING: Could not connect to Redis: %v. Falling back to in-memory rate limiter.\n", err)
return rateLimitByIPFallback(cfg.RateLimit.RequestsPerMinute)
}
limiter := redis_rate.NewLimiter(rdb)
return func(c *gin.Context) {
res, err := limiter.Allow(c.Request.Context(), c.ClientIP(), redis_rate.PerMinute(cfg.RateLimit.RequestsPerMinute))
if err != nil {
fmt.Printf("Rate limiter error: %v\n", err)
c.Next()
return
}
h := c.Writer.Header()
h.Set("X-RateLimit-Limit", fmt.Sprintf("%d", cfg.RateLimit.RequestsPerMinute))
h.Set("X-RateLimit-Remaining", fmt.Sprintf("%d", res.Remaining))
if res.Allowed == 0 {
h.Set("X-RateLimit-Reset", fmt.Sprintf("%d", time.Now().Add(res.RetryAfter).Unix()))
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
"error": "Rate limit exceeded",
})
return
}
c.Next()
}
}
// rateLimitByIPFallback adalah rate limiter sederhana di memori, HANYA untuk pengembangan
func rateLimitByIPFallback(requestsPerMinute int) gin.HandlerFunc {
type client struct {
count int
resetTime int64
}
clients := make(map[string]*client)
return func(c *gin.Context) {
ip := c.ClientIP()
now := time.Now().Unix()
if _, exists := clients[ip]; !exists {
clients[ip] = &client{count: 0, resetTime: now + 60}
}
cl := clients[ip]
if now > cl.resetTime {
cl.count = 0
cl.resetTime = now + 60
}
if cl.count >= requestsPerMinute {
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "Rate limit exceeded"})
return
}
cl.count++
c.Next()
}
}
// InputValidation memvalidasi input untuk mencegah serangan injeksi dan buffer overflow
func InputValidation(cfg config.SecurityConfig) gin.HandlerFunc {
// Pola-pola yang mencurigakan. Ini adalah lapisan pertahanan tambahan (WAF), bukan pengganti prepared statements.
suspiciousPatterns := []string{
"union select", "union all select", "select.*from", "insert.*into", "update.*set", "delete.*from",
"drop table", "drop database", "alter table", "create table", "exec(", "execute(", "xp_", "sp_",
"information_schema", "sysobjects", "syscolumns", "mysql.", "pg_", "sqlite_", ";--", "/*", "*/",
"@@", "script>", "<script", "javascript:", "vbscript:", "onload=", "onerror=", "eval(", "alert(",
}
return func(c *gin.Context) {
// 1. Validasi Panjang Input
log.Printf("DEBUG: InputValidation middleware called. MaxInputLength is set to: %d", cfg.MaxInputLength)
if !validateInputLength(c, cfg.MaxInputLength) {
return
}
// 2. Deteksi Pola Injeksi
if hasInjectionPatterns(c, suspiciousPatterns) {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
"error": "Invalid input detected",
"message": "Request contains potentially malicious content",
})
return
}
c.Next()
}
}
// validateInputLength memeriksa panjang input pada query dan form
func validateInputLength(c *gin.Context, maxLength int) bool {
log.Printf("DEBUG: Full Raw Query Received: %s", c.Request.URL.RawQuery)
// Periksa query parameters
for key, values := range c.Request.URL.Query() {
for _, value := range values {
log.Printf("DEBUG: Checking param '%s' with value '%s' (length: %d)", key, value, len(value))
if len(value) > maxLength {
log.Printf("ERROR: Parameter '%s' with value '%s' (length: %d) exceeds max length %d", key, value, len(value), maxLength)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
"error": "Input too long",
"message": fmt.Sprintf("Query parameter '%s' exceeds maximum length", key),
})
return false
}
}
}
// Periksa form data (jika sudah di-parse)
if c.Request.PostForm != nil {
for key, values := range c.Request.PostForm {
for _, value := range values {
if len(value) > maxLength {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
"error": "Input too long",
"message": fmt.Sprintf("Form parameter '%s' exceeds maximum length", key),
})
return false
}
}
}
}
return true
}
// hasInjectionPatterns memeriksa pola injeksi pada query, form, dan body JSON
func hasInjectionPatterns(c *gin.Context, patterns []string) bool {
// Periksa query string
query := strings.ToLower(c.Request.URL.RawQuery)
for _, pattern := range patterns {
if strings.Contains(query, pattern) {
return true
}
}
// Periksa form data
if err := c.Request.ParseForm(); err == nil {
for _, values := range c.Request.Form {
for _, value := range values {
lowerValue := strings.ToLower(value)
for _, pattern := range patterns {
if strings.Contains(lowerValue, pattern) {
return true
}
}
}
}
}
// Periksa body JSON
if c.ContentType() == "application/json" {
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
return false
}
// **PENTING**: Kembalikan body agar bisa dibaca lagi oleh handler (misalnya c.ShouldBindJSON)
c.Request.Body = io.NopCloser(strings.NewReader(string(bodyBytes)))
var jsonData map[string]interface{}
if err := json.Unmarshal(bodyBytes, &jsonData); err == nil {
if checkMapForPatterns(jsonData, patterns) {
return true
}
}
}
return false
}
// checkMapForPatterns memeriksa nilai-nilai di dalam map JSON secara rekursif
func checkMapForPatterns(data map[string]interface{}, patterns []string) bool {
for _, value := range data {
if checkValueForPatterns(value, patterns) {
return true
}
}
return false
}
func checkValueForPatterns(value interface{}, patterns []string) bool {
switch v := value.(type) {
case string:
lowerValue := strings.ToLower(v)
for _, pattern := range patterns {
if strings.Contains(lowerValue, pattern) {
return true
}
}
case map[string]interface{}:
return checkMapForPatterns(v, patterns)
case []interface{}:
for _, item := range v {
if checkValueForPatterns(item, patterns) {
return true
}
}
}
return false
}