Update besar
This commit is contained in:
+293
-60
@@ -13,16 +13,20 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/spf13/viper"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server ServerConfig
|
||||
Databases map[string]DatabaseConfig
|
||||
ReadReplicas map[string][]DatabaseConfig // For read replicas
|
||||
Auth AuthConfig
|
||||
Keycloak KeycloakConfig
|
||||
Bpjs BpjsConfig
|
||||
SatuSehat SatuSehatConfig
|
||||
Swagger SwaggerConfig
|
||||
Security SecurityConfig
|
||||
Validator *validator.Validate
|
||||
}
|
||||
|
||||
@@ -63,6 +67,25 @@ type DatabaseConfig struct {
|
||||
ConnMaxLifetime time.Duration // Connection max lifetime
|
||||
}
|
||||
|
||||
type AuthConfig struct {
|
||||
Type string `yaml:"type" env:"AUTH_TYPE"` // "keycloak", "jwt", "static", "hybrid"
|
||||
StaticTokens []string `yaml:"static_tokens" env:"AUTH_STATIC_TOKENS"` // Support multiple static tokens
|
||||
FallbackTo string `yaml:"fallback_to" env:"AUTH_FALLBACK_TO"` // fallback auth type if primary fails
|
||||
}
|
||||
|
||||
// AuthYAMLConfig represents the auth section in config.yaml
|
||||
type AuthYAMLConfig struct {
|
||||
Type string `yaml:"type"`
|
||||
StaticTokens []string `yaml:"static_tokens"`
|
||||
FallbackTo string `yaml:"fallback_to"`
|
||||
}
|
||||
type KeycloakYAMLConfig struct {
|
||||
Issuer string `yaml:"issuer"`
|
||||
Audience string `yaml:"audience"`
|
||||
JwksURL string `yaml:"jwks_url"`
|
||||
Enabled bool `yaml:"enabled"`
|
||||
}
|
||||
|
||||
type KeycloakConfig struct {
|
||||
Issuer string
|
||||
Audience string
|
||||
@@ -90,27 +113,30 @@ type SatuSehatConfig struct {
|
||||
Timeout time.Duration `json:"timeout"`
|
||||
}
|
||||
|
||||
// SetHeader generates required headers for BPJS VClaim API
|
||||
// func (cfg BpjsConfig) SetHeader() (string, string, string, string, string) {
|
||||
// timenow := time.Now().UTC()
|
||||
// t, err := time.Parse(time.RFC3339, "1970-01-01T00:00:00Z")
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
// SecurityConfig berisi semua pengaturan untuk middleware keamanan
|
||||
type SecurityConfig struct {
|
||||
// CORS
|
||||
TrustedOrigins []string `mapstructure:"trusted_origins"`
|
||||
// Rate Limiting
|
||||
RateLimit RateLimitConfig `mapstructure:"rate_limit"`
|
||||
// Input Validation
|
||||
MaxInputLength int `mapstructure:"max_input_length"`
|
||||
}
|
||||
|
||||
// tstamp := timenow.Unix() - t.Unix()
|
||||
// secret := []byte(cfg.SecretKey)
|
||||
// message := []byte(cfg.ConsID + "&" + fmt.Sprint(tstamp))
|
||||
// hash := hmac.New(sha256.New, secret)
|
||||
// hash.Write(message)
|
||||
// RateLimitConfig berisi pengaturan untuk rate limiter
|
||||
type RateLimitConfig struct {
|
||||
RequestsPerMinute int `mapstructure:"requests_per_minute"`
|
||||
Redis RedisConfig `mapstructure:"redis"`
|
||||
}
|
||||
|
||||
// // to lowercase hexits
|
||||
// hex.EncodeToString(hash.Sum(nil))
|
||||
// // to base64
|
||||
// xSignature := base64.StdEncoding.EncodeToString(hash.Sum(nil))
|
||||
// RedisConfig berisi detail koneksi ke Redis
|
||||
type RedisConfig struct {
|
||||
Host string `mapstructure:"host"`
|
||||
Port int `mapstructure:"port"`
|
||||
Password string `mapstructure:"password"`
|
||||
DB int `mapstructure:"db"`
|
||||
}
|
||||
|
||||
// return cfg.ConsID, cfg.SecretKey, cfg.UserKey, fmt.Sprint(tstamp), xSignature
|
||||
// }
|
||||
func (cfg BpjsConfig) SetHeader() (string, string, string, string, string) {
|
||||
timenow := time.Now().UTC()
|
||||
t, err := time.Parse(time.RFC3339, "1970-01-01T00:00:00Z")
|
||||
@@ -149,6 +175,7 @@ func (cfg ConfigBpjs) SetHeader() (string, string, string, string, string) {
|
||||
}
|
||||
|
||||
func LoadConfig() *Config {
|
||||
log.Printf("DEBUG: Raw ENV for SECURITY_MAX_INPUT_LENGTH is: '%s'", os.Getenv("SECURITY_MAX_INPUT_LENGTH"))
|
||||
config := &Config{
|
||||
Server: ServerConfig{
|
||||
Port: getEnvAsInt("PORT", 8080),
|
||||
@@ -156,12 +183,8 @@ func LoadConfig() *Config {
|
||||
},
|
||||
Databases: make(map[string]DatabaseConfig),
|
||||
ReadReplicas: make(map[string][]DatabaseConfig),
|
||||
Keycloak: KeycloakConfig{
|
||||
Issuer: getEnv("KEYCLOAK_ISSUER", "https://keycloak.example.com/auth/realms/yourrealm"),
|
||||
Audience: getEnv("KEYCLOAK_AUDIENCE", "your-client-id"),
|
||||
JwksURL: getEnv("KEYCLOAK_JWKS_URL", "https://keycloak.example.com/auth/realms/yourrealm/protocol/openid-connect/certs"),
|
||||
Enabled: getEnvAsBool("KEYCLOAK_ENABLED", true),
|
||||
},
|
||||
Auth: loadAuthConfig(),
|
||||
Keycloak: loadKeycloakConfig(),
|
||||
Bpjs: BpjsConfig{
|
||||
BaseURL: getEnv("BPJS_BASEURL", "https://apijkn.bpjs-kesehatan.go.id"),
|
||||
ConsID: getEnv("BPJS_CONSID", ""),
|
||||
@@ -194,8 +217,21 @@ func LoadConfig() *Config {
|
||||
BasePath: getEnv("SWAGGER_BASE_PATH", "/api/v1"),
|
||||
Schemes: parseSchemes(getEnv("SWAGGER_SCHEMES", "http,https")),
|
||||
},
|
||||
Security: SecurityConfig{
|
||||
TrustedOrigins: parseOrigins(getEnv("SECURITY_TRUSTED_ORIGINS", "http://localhost:3000,http://localhost:8080")),
|
||||
MaxInputLength: getEnvAsInt("SECURITY_MAX_INPUT_LENGTH", 500),
|
||||
RateLimit: RateLimitConfig{
|
||||
RequestsPerMinute: getEnvAsInt("RATE_LIMIT_REQUESTS_PER_MINUTE", 60),
|
||||
Redis: RedisConfig{
|
||||
Host: getEnv("REDIS_HOST", "localhost"),
|
||||
Port: getEnvAsInt("REDIS_PORT", 6379),
|
||||
Password: getEnv("REDIS_PASSWORD", ""),
|
||||
DB: getEnvAsInt("REDIS_DB", 0),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
log.Printf("DEBUG: Final Config Object. MaxInputLength is: %d", config.Security.MaxInputLength)
|
||||
// Initialize validator
|
||||
config.Validator = validator.New()
|
||||
|
||||
@@ -205,28 +241,155 @@ func LoadConfig() *Config {
|
||||
// Load read replica configurations
|
||||
config.loadReadReplicaConfigs()
|
||||
|
||||
log.Printf("DEBUG [LoadConfig]: Config object created at address: %p", config)
|
||||
log.Printf("DEBUG [LoadConfig]: Security.MaxInputLength is: %d", config.Security.MaxInputLength)
|
||||
return config
|
||||
}
|
||||
|
||||
func loadAuthConfig() AuthConfig {
|
||||
// --- AWAL TAMBAHAN DEBUG ---
|
||||
// Cetak direktori kerja saat ini untuk debugging
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
log.Printf("Error getting working directory: %v", err)
|
||||
} else {
|
||||
log.Printf("DEBUG: Current working directory is: %s", wd)
|
||||
}
|
||||
// --- AKHIR TAMBAHAN DEBUG ---
|
||||
|
||||
authConfig := AuthConfig{
|
||||
Type: "jwt", // default to jwt for backward compatibility
|
||||
FallbackTo: "",
|
||||
StaticTokens: []string{},
|
||||
}
|
||||
|
||||
// Path file yang akan dibaca
|
||||
configPath := "internal/config/config.yaml"
|
||||
log.Printf("DEBUG: Attempting to read auth config from: %s", configPath)
|
||||
|
||||
// Load auth configuration from config.yaml first
|
||||
if data, err := os.ReadFile(configPath); err == nil {
|
||||
log.Printf("DEBUG: Successfully read config.yaml file. Parsing...") // Tambahkan log sukses
|
||||
|
||||
var yamlConfig struct {
|
||||
Auth AuthYAMLConfig `yaml:"auth"`
|
||||
}
|
||||
if err := yaml.Unmarshal(data, &yamlConfig); err == nil {
|
||||
// Log nilai yang berhasil dibaca
|
||||
log.Printf("DEBUG: Parsed YAML. Type: '%s', Tokens: %d", yamlConfig.Auth.Type, len(yamlConfig.Auth.StaticTokens))
|
||||
|
||||
authConfig.Type = yamlConfig.Auth.Type
|
||||
authConfig.FallbackTo = yamlConfig.Auth.FallbackTo
|
||||
authConfig.StaticTokens = yamlConfig.Auth.StaticTokens
|
||||
} else {
|
||||
log.Printf("ERROR: Failed to unmarshal YAML: %v", err)
|
||||
}
|
||||
} else {
|
||||
// --- AWAL TAMBAHAN DEBUG ---
|
||||
// Cetak error spesifik jika file tidak ditemukan
|
||||
log.Printf("ERROR: Could not read config file at '%s': %v", configPath, err)
|
||||
// --- AKHIR TAMBAHAN DEBUG ---
|
||||
}
|
||||
|
||||
// Then override with environment variables if set
|
||||
if envType := getEnv("AUTH_TYPE", ""); envType != "" {
|
||||
log.Printf("DEBUG: Overriding auth type with environment variable: %s", envType)
|
||||
authConfig.Type = envType
|
||||
}
|
||||
if envFallback := getEnv("AUTH_FALLBACK_TO", ""); envFallback != "" {
|
||||
authConfig.FallbackTo = envFallback
|
||||
}
|
||||
envTokens := parseStaticTokens(getEnv("AUTH_STATIC_TOKENS", ""))
|
||||
if len(envTokens) > 0 {
|
||||
authConfig.StaticTokens = envTokens
|
||||
}
|
||||
|
||||
// Log hasil akhir sebelum dikembalikan
|
||||
log.Printf("DEBUG: Final AuthConfig before returning: Type='%s', TokenCount=%d", authConfig.Type, len(authConfig.StaticTokens))
|
||||
|
||||
return authConfig
|
||||
}
|
||||
|
||||
// Lakukan hal yang sama untuk loadKeycloakConfig
|
||||
func loadKeycloakConfig() KeycloakConfig {
|
||||
// --- AWAL TAMBAHAN DEBUG ---
|
||||
// Cetak direktori kerja saat ini untuk debugging
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
log.Printf("Error getting working directory for keycloak config: %v", err)
|
||||
} else {
|
||||
log.Printf("DEBUG (Keycloak): Current working directory is: %s", wd)
|
||||
}
|
||||
// --- AKHIR TAMBAHAN DEBUG ---
|
||||
|
||||
v := viper.New()
|
||||
v.SetConfigName("config")
|
||||
v.SetConfigType("yaml")
|
||||
v.AddConfigPath(".")
|
||||
v.AddConfigPath("./config")
|
||||
v.AddConfigPath("./internal/config")
|
||||
|
||||
// --- AWAL TAMBAHAN DEBUG ---
|
||||
log.Printf("DEBUG (Keycloak): Viper is set to search for config in: '.', './config', './internal/config'")
|
||||
// --- AKHIR TAMBAHAN DEBUG ---
|
||||
|
||||
if err := v.ReadInConfig(); err == nil {
|
||||
// Log jika file berhasil ditemukan dan dibaca
|
||||
log.Printf("DEBUG (Keycloak): Successfully read config file: %s", v.ConfigFileUsed())
|
||||
|
||||
keycloakConfig := KeycloakConfig{
|
||||
Issuer: v.GetString("keycloak.issuer"),
|
||||
Audience: v.GetString("keycloak.audience"),
|
||||
JwksURL: v.GetString("keycloak.jwks_url"),
|
||||
Enabled: v.GetBool("keycloak.enabled"),
|
||||
}
|
||||
|
||||
// Log nilai yang berhasil dibaca dari file
|
||||
log.Printf("DEBUG (Keycloak): Parsed values from file. Issuer: '%s', Enabled: %t", keycloakConfig.Issuer, keycloakConfig.Enabled)
|
||||
|
||||
log.Printf("Loaded keycloak config from file: enabled=%t", keycloakConfig.Enabled)
|
||||
return keycloakConfig
|
||||
} else {
|
||||
// --- AWAL TAMBAHAN DEBUG ---
|
||||
// Cetak error spesifik jika file tidak ditemukan
|
||||
log.Printf("ERROR (Keycloak): Could not read config file: %v", err)
|
||||
// --- AKHIR TAMBAHAN DEBUG ---
|
||||
}
|
||||
|
||||
// Fallback ke environment variable
|
||||
log.Printf("DEBUG (Keycloak): Falling back to environment variables.")
|
||||
fallbackConfig := KeycloakConfig{
|
||||
Issuer: getEnv("KEYCLOAK_ISSUER", ""),
|
||||
Audience: getEnv("KEYCLOAK_AUDIENCE", ""),
|
||||
JwksURL: getEnv("KEYCLOAK_JWKS_URL", ""),
|
||||
Enabled: getEnvAsBool("KEYCLOAK_ENABLED", false),
|
||||
}
|
||||
|
||||
// Log hasil akhir dari fallback
|
||||
log.Printf("DEBUG (Keycloak): Final fallback config. Issuer: '%s', Enabled: %t", fallbackConfig.Issuer, fallbackConfig.Enabled)
|
||||
|
||||
return fallbackConfig
|
||||
}
|
||||
|
||||
func (c *Config) loadDatabaseConfigs() {
|
||||
// Simplified approach: Directly load from environment variables
|
||||
// This ensures we get the exact values specified in .env
|
||||
|
||||
// Primary database configuration
|
||||
c.Databases["default"] = DatabaseConfig{
|
||||
Name: "default",
|
||||
Type: getEnv("DB_CONNECTION", "postgres"),
|
||||
Host: getEnv("DB_HOST", "localhost"),
|
||||
Port: getEnvAsInt("DB_PORT", 5432),
|
||||
Username: getEnv("DB_USERNAME", ""),
|
||||
Password: getEnv("DB_PASSWORD", ""),
|
||||
Database: getEnv("DB_DATABASE", "satu_db"),
|
||||
Schema: getEnv("DB_SCHEMA", "public"),
|
||||
SSLMode: getEnv("DB_SSLMODE", "disable"),
|
||||
MaxOpenConns: getEnvAsInt("DB_MAX_OPEN_CONNS", 25),
|
||||
MaxIdleConns: getEnvAsInt("DB_MAX_IDLE_CONNS", 25),
|
||||
ConnMaxLifetime: parseDuration(getEnv("DB_CONN_MAX_LIFETIME", "5m")),
|
||||
}
|
||||
// // Primary database configuration
|
||||
// c.Databases["default"] = DatabaseConfig{
|
||||
// Name: "default",
|
||||
// Type: getEnv("DB_CONNECTION", "postgres"),
|
||||
// Host: getEnv("DB_HOST", "localhost"),
|
||||
// Port: getEnvAsInt("DB_PORT", 5432),
|
||||
// Username: getEnv("DB_USERNAME", ""),
|
||||
// Password: getEnv("DB_PASSWORD", ""),
|
||||
// Database: getEnv("DB_DATABASE", "satu_db"),
|
||||
// Schema: getEnv("DB_SCHEMA", "public"),
|
||||
// SSLMode: getEnv("DB_SSLMODE", "disable"),
|
||||
// MaxOpenConns: getEnvAsInt("DB_MAX_OPEN_CONNS", 25),
|
||||
// MaxIdleConns: getEnvAsInt("DB_MAX_IDLE_CONNS", 25),
|
||||
// ConnMaxLifetime: parseDuration(getEnv("DB_CONN_MAX_LIFETIME", "5m")),
|
||||
// }
|
||||
|
||||
// SATUDATA database configuration
|
||||
c.addPostgreSQLConfigs()
|
||||
@@ -669,71 +832,141 @@ func parseSchemes(schemesStr string) []string {
|
||||
return schemes
|
||||
}
|
||||
|
||||
// parseStaticTokens parses comma-separated static tokens string into a slice
|
||||
func parseStaticTokens(tokensStr string) []string {
|
||||
if tokensStr == "" {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
tokens := strings.Split(tokensStr, ",")
|
||||
for i, token := range tokens {
|
||||
tokens[i] = strings.TrimSpace(token)
|
||||
// Remove empty tokens
|
||||
if tokens[i] == "" {
|
||||
tokens = append(tokens[:i], tokens[i+1:]...)
|
||||
i--
|
||||
}
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
func parseOrigins(originsStr string) []string {
|
||||
if originsStr == "" {
|
||||
return []string{"http://localhost:8080"} // Default untuk pengembangan
|
||||
}
|
||||
origins := strings.Split(originsStr, ",")
|
||||
for i, origin := range origins {
|
||||
origins[i] = strings.TrimSpace(origin)
|
||||
}
|
||||
return origins
|
||||
}
|
||||
|
||||
func (c *Config) Validate() error {
|
||||
var errs []string
|
||||
|
||||
if len(c.Databases) == 0 {
|
||||
log.Fatal("At least one database configuration is required")
|
||||
errs = append(errs, "at least one database configuration is required")
|
||||
}
|
||||
|
||||
for name, db := range c.Databases {
|
||||
if db.Host == "" {
|
||||
log.Fatalf("Database host is required for %s", name)
|
||||
errs = append(errs, fmt.Sprintf("database host is required for %s", name))
|
||||
}
|
||||
if db.Username == "" {
|
||||
log.Fatalf("Database username is required for %s", name)
|
||||
errs = append(errs, fmt.Sprintf("database username is required for %s", name))
|
||||
}
|
||||
if db.Password == "" {
|
||||
log.Fatalf("Database password is required for %s", name)
|
||||
errs = append(errs, fmt.Sprintf("database password is required for %s", name))
|
||||
}
|
||||
if db.Database == "" {
|
||||
log.Fatalf("Database name is required for %s", name)
|
||||
errs = append(errs, fmt.Sprintf("database name is required for %s", name))
|
||||
}
|
||||
}
|
||||
|
||||
if c.Bpjs.BaseURL == "" {
|
||||
log.Fatal("BPJS Base URL is required")
|
||||
errs = append(errs, "BPJS Base URL is required")
|
||||
}
|
||||
if c.Bpjs.ConsID == "" {
|
||||
log.Fatal("BPJS Consumer ID is required")
|
||||
errs = append(errs, "BPJS Consumer ID is required")
|
||||
}
|
||||
if c.Bpjs.UserKey == "" {
|
||||
log.Fatal("BPJS User Key is required")
|
||||
errs = append(errs, "BPJS User Key is required")
|
||||
}
|
||||
if c.Bpjs.SecretKey == "" {
|
||||
log.Fatal("BPJS Secret Key is required")
|
||||
errs = append(errs, "BPJS Secret Key is required")
|
||||
}
|
||||
|
||||
// Validate Keycloak configuration if enabled
|
||||
if c.Keycloak.Enabled {
|
||||
// Validate authentication configuration
|
||||
switch c.Auth.Type {
|
||||
case "keycloak":
|
||||
if !c.Keycloak.Enabled {
|
||||
errs = append(errs, "keycloak.enabled must be true when auth.type is 'keycloak'")
|
||||
}
|
||||
if c.Keycloak.Issuer == "" {
|
||||
log.Fatal("Keycloak issuer is required when Keycloak is enabled")
|
||||
errs = append(errs, "keycloak.issuer is required when auth.type is 'keycloak'")
|
||||
}
|
||||
if c.Keycloak.Audience == "" {
|
||||
log.Fatal("Keycloak audience is required when Keycloak is enabled")
|
||||
errs = append(errs, "keycloak.audience is required when auth.type is 'keycloak'")
|
||||
}
|
||||
if c.Keycloak.JwksURL == "" {
|
||||
log.Fatal("Keycloak JWKS URL is required when Keycloak is enabled")
|
||||
errs = append(errs, "keycloak.jwks_url is required when auth.type is 'keycloak'")
|
||||
}
|
||||
case "static":
|
||||
if len(c.Auth.StaticTokens) == 0 {
|
||||
errs = append(errs, "auth.static_tokens is required when auth.type is 'static'")
|
||||
}
|
||||
case "hybrid":
|
||||
if c.Auth.FallbackTo == "" {
|
||||
errs = append(errs, "auth.fallback_to is required when auth.type is 'hybrid'")
|
||||
}
|
||||
// Validate fallback configuration
|
||||
switch c.Auth.FallbackTo {
|
||||
case "keycloak":
|
||||
if !c.Keycloak.Enabled {
|
||||
errs = append(errs, "keycloak.enabled must be true when auth.fallback_to is 'keycloak'")
|
||||
}
|
||||
case "static":
|
||||
if len(c.Auth.StaticTokens) == 0 {
|
||||
errs = append(errs, "auth.static_tokens is required when auth.fallback_to is 'static'")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy validation for backward compatibility
|
||||
if c.Auth.Type != "keycloak" && c.Keycloak.Enabled {
|
||||
if c.Keycloak.Issuer == "" {
|
||||
errs = append(errs, "Keycloak issuer is required when Keycloak is enabled")
|
||||
}
|
||||
if c.Keycloak.Audience == "" {
|
||||
errs = append(errs, "Keycloak audience is required when Keycloak is enabled")
|
||||
}
|
||||
if c.Keycloak.JwksURL == "" {
|
||||
errs = append(errs, "Keycloak JWKS URL is required when Keycloak is enabled")
|
||||
}
|
||||
}
|
||||
|
||||
// Validate SatuSehat configuration
|
||||
if c.SatuSehat.OrgID == "" {
|
||||
log.Fatal("SatuSehat Organization ID is required")
|
||||
errs = append(errs, "SatuSehat Organization ID is required")
|
||||
}
|
||||
if c.SatuSehat.FasyakesID == "" {
|
||||
log.Fatal("SatuSehat Fasyankes ID is required")
|
||||
errs = append(errs, "SatuSehat Fasyankes ID is required")
|
||||
}
|
||||
if c.SatuSehat.ClientID == "" {
|
||||
log.Fatal("SatuSehat Client ID is required")
|
||||
errs = append(errs, "SatuSehat Client ID is required")
|
||||
}
|
||||
if c.SatuSehat.ClientSecret == "" {
|
||||
log.Fatal("SatuSehat Client Secret is required")
|
||||
errs = append(errs, "SatuSehat Client Secret is required")
|
||||
}
|
||||
if c.SatuSehat.AuthURL == "" {
|
||||
log.Fatal("SatuSehat Auth URL is required")
|
||||
errs = append(errs, "SatuSehat Auth URL is required")
|
||||
}
|
||||
if c.SatuSehat.BaseURL == "" {
|
||||
log.Fatal("SatuSehat Base URL is required")
|
||||
errs = append(errs, "SatuSehat Base URL is required")
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("configuration validation failed: %s", strings.Join(errs, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
auth:
|
||||
type: static # Options: jwt, keycloak, static, hybrid (for hybrid mode keycloak is primary and jwt is fallback)
|
||||
static_tokens:
|
||||
- token1
|
||||
- token2
|
||||
- token3
|
||||
- token4
|
||||
fallback_to: jwt # Options: keycloak, static, jwt (for hybrid mode keycloak is primary and jwt is fallback)
|
||||
keycloak:
|
||||
enabled: true
|
||||
issuer: https://auth.rssa.top/realms/sandbox
|
||||
audience: nuxtsim-pendaftaran
|
||||
jwks_url: https://auth.rssa.top/realms/sandbox/protocol/openid-connect/certs
|
||||
|
||||
+111
-14
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"api-service/internal/models/auth"
|
||||
models "api-service/internal/models/auth"
|
||||
services "api-service/internal/services/auth"
|
||||
"net/http"
|
||||
@@ -62,9 +63,22 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
// @Failure 401 {object} map[string]string "Unauthorized"
|
||||
// @Router /api/v1/auth/refresh [post]
|
||||
func (h *AuthHandler) RefreshToken(c *gin.Context) {
|
||||
// For now, this is a placeholder for refresh token functionality
|
||||
// In a real implementation, you would handle refresh tokens here
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"error": "refresh token not implemented"})
|
||||
var refreshReq auth.RefreshTokenRequest
|
||||
|
||||
// Bind JSON request
|
||||
if err := c.ShouldBindJSON(&refreshReq); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Refresh token
|
||||
tokenResponse, err := h.authService.RefreshToken(refreshReq.RefreshToken)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, tokenResponse)
|
||||
}
|
||||
|
||||
// Register godoc
|
||||
@@ -78,12 +92,7 @@ func (h *AuthHandler) RefreshToken(c *gin.Context) {
|
||||
// @Failure 400 {object} map[string]string "Bad request"
|
||||
// @Router /api/v1/auth/register [post]
|
||||
func (h *AuthHandler) Register(c *gin.Context) {
|
||||
var registerReq struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
Role string `json:"role" binding:"required"`
|
||||
}
|
||||
var registerReq auth.RegisterRequest
|
||||
|
||||
if err := c.ShouldBindJSON(®isterReq); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
@@ -123,10 +132,98 @@ func (h *AuthHandler) Me(c *gin.Context) {
|
||||
}
|
||||
|
||||
// In a real implementation, you would fetch user details from database
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": userID,
|
||||
"username": c.GetString("username"),
|
||||
"email": c.GetString("email"),
|
||||
"role": c.GetString("role"),
|
||||
c.JSON(http.StatusOK, auth.UserResponse{
|
||||
ID: userID.(string),
|
||||
Username: c.GetString("username"),
|
||||
Email: c.GetString("email"),
|
||||
Role: c.GetString("role"),
|
||||
})
|
||||
}
|
||||
|
||||
// TokenHandler handles token generation endpoints
|
||||
type TokenHandler struct {
|
||||
authService *services.AuthService
|
||||
}
|
||||
|
||||
// NewTokenHandler creates a new token handler
|
||||
func NewTokenHandler(authService *services.AuthService) *TokenHandler {
|
||||
return &TokenHandler{
|
||||
authService: authService,
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateToken godoc
|
||||
// @Summary Generate JWT token
|
||||
// @Description Generate a JWT token for testing purposes
|
||||
// @Tags Token
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param token body map[string]interface{} true "Token generation data"
|
||||
// @Success 200 {object} models.TokenResponse
|
||||
// @Failure 400 {object} map[string]string "Bad request"
|
||||
// @Router /api/v1/token/generate [post]
|
||||
func (h *TokenHandler) GenerateToken(c *gin.Context) {
|
||||
var req map[string]interface{}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Extract user data from request
|
||||
userID, ok := req["user_id"].(string)
|
||||
if !ok || userID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "user_id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
username, _ := req["username"].(string)
|
||||
email, _ := req["email"].(string)
|
||||
role, _ := req["role"].(string)
|
||||
|
||||
// Generate token
|
||||
tokenResponse, err := h.authService.GenerateToken(userID, username, email, role)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, tokenResponse)
|
||||
}
|
||||
|
||||
// GenerateTokenDirect godoc
|
||||
// @Summary Generate JWT token directly
|
||||
// @Description Generate a JWT token directly with provided data
|
||||
// @Tags Token
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param token body map[string]interface{} true "Token generation data"
|
||||
// @Success 200 {object} models.TokenResponse
|
||||
// @Failure 400 {object} map[string]string "Bad request"
|
||||
// @Router /api/v1/token/generate-direct [post]
|
||||
func (h *TokenHandler) GenerateTokenDirect(c *gin.Context) {
|
||||
var req map[string]interface{}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Extract user data from request
|
||||
userID, ok := req["user_id"].(string)
|
||||
if !ok || userID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "user_id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
username, _ := req["username"].(string)
|
||||
email, _ := req["email"].(string)
|
||||
role, _ := req["role"].(string)
|
||||
|
||||
// Generate token directly
|
||||
tokenResponse, err := h.authService.GenerateTokenDirect(userID, username, email, role)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, tokenResponse)
|
||||
}
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
models "api-service/internal/models/auth"
|
||||
services "api-service/internal/services/auth"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// TokenHandler handles token generation endpoints
|
||||
type TokenHandler struct {
|
||||
authService *services.AuthService
|
||||
}
|
||||
|
||||
// NewTokenHandler creates a new token handler
|
||||
func NewTokenHandler(authService *services.AuthService) *TokenHandler {
|
||||
return &TokenHandler{
|
||||
authService: authService,
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateToken godoc
|
||||
// @Summary Generate JWT token
|
||||
// @Description Generate a JWT token for a user
|
||||
// @Tags Token
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param token body models.LoginRequest true "User credentials"
|
||||
// @Success 200 {object} models.TokenResponse
|
||||
// @Failure 400 {object} map[string]string "Bad request"
|
||||
// @Failure 401 {object} map[string]string "Unauthorized"
|
||||
// @Router /api/v1/token/generate [post]
|
||||
func (h *TokenHandler) GenerateToken(c *gin.Context) {
|
||||
var loginReq models.LoginRequest
|
||||
|
||||
// Bind JSON request
|
||||
if err := c.ShouldBindJSON(&loginReq); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate token
|
||||
tokenResponse, err := h.authService.Login(loginReq.Username, loginReq.Password)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, tokenResponse)
|
||||
}
|
||||
|
||||
// GenerateTokenDirect godoc
|
||||
// @Summary Generate token directly
|
||||
// @Description Generate a JWT token directly without password verification (for testing)
|
||||
// @Tags Token
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param user body map[string]string true "User info"
|
||||
// @Success 200 {object} models.TokenResponse
|
||||
// @Failure 400 {object} map[string]string "Bad request"
|
||||
// @Router /api/v1/token/generate-direct [post]
|
||||
func (h *TokenHandler) GenerateTokenDirect(c *gin.Context) {
|
||||
var req struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Email string `json:"email" binding:"required"`
|
||||
Role string `json:"role" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Create a temporary user for token generation
|
||||
user := &models.User{
|
||||
ID: "temp-" + req.Username,
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
Role: req.Role,
|
||||
}
|
||||
|
||||
// Generate token directly
|
||||
token, err := h.authService.GenerateTokenForUser(user)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.TokenResponse{
|
||||
AccessToken: token,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: 3600,
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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: "static@example.com",
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
package models
|
||||
package auth
|
||||
|
||||
import (
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// LoginRequest represents the login request payload
|
||||
type LoginRequest struct {
|
||||
@@ -8,17 +12,32 @@ type LoginRequest struct {
|
||||
|
||||
// TokenResponse represents the token response
|
||||
type TokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
TokenType string `json:"token_type"` // Biasanya "Bearer"
|
||||
ExpiresIn int64 `json:"expires_in"` // Durasi dalam detik
|
||||
}
|
||||
|
||||
// JWTClaims represents the JWT claims
|
||||
type JWTClaims struct {
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
UserID string `json:"sub"` // Gunakan "sub" (subject) sebagai standar untuk ID pengguna
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
jwt.RegisteredClaims // Menanamkan klaim standar (exp, iat, iss, aud, dll.)
|
||||
}
|
||||
|
||||
// RegisterRequest represents the register request payload
|
||||
type RegisterRequest struct {
|
||||
Username string `json:"username" binding:"required,min=3,max=50"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
Role string `json:"role" binding:"required,oneof=admin user"` // Contoh validasi role
|
||||
}
|
||||
|
||||
// RefreshTokenRequest represents the refresh token request payload
|
||||
type RefreshTokenRequest struct {
|
||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||
}
|
||||
|
||||
// User represents a user for authentication
|
||||
@@ -26,6 +45,14 @@ type User struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"-"`
|
||||
Password string `json:"-"` // Tidak disertakan saat di-serialize ke JSON
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// UserResponse represents user data that can be safely returned to the client
|
||||
type UserResponse struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
+114
-83
@@ -9,40 +9,67 @@ import (
|
||||
"api-service/internal/middleware"
|
||||
services "api-service/internal/services/auth"
|
||||
"api-service/pkg/logger"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
swaggerFiles "github.com/swaggo/files"
|
||||
ginSwagger "github.com/swaggo/gin-swagger"
|
||||
)
|
||||
|
||||
func RegisterRoutes(cfg *config.Config) *gin.Engine {
|
||||
// Atur mode Gin berdasarkan konfigurasi
|
||||
gin.SetMode(cfg.Server.Mode)
|
||||
router := gin.New()
|
||||
|
||||
// Initialize auth middleware configuration
|
||||
// =============================================================================
|
||||
// GLOBAL MIDDLEWARE STACK (Middleware yang diperlukan SEMUA route)
|
||||
// =============================================================================
|
||||
middleware.InitializeAuth(cfg)
|
||||
|
||||
// Add global middleware
|
||||
router.Use(middleware.CORSConfig())
|
||||
router.Use(middleware.ErrorHandler())
|
||||
router.Use(logger.RequestLoggerMiddleware(logger.Default()))
|
||||
router.Use(gin.Recovery())
|
||||
// 1. CORS (Paling awal)
|
||||
router.Use(middleware.SecureCORSConfig(cfg.Security))
|
||||
// 2. Rate Limiting
|
||||
router.Use(middleware.RateLimitByIPRedis(cfg.Security))
|
||||
// 3. Logging & Recovery
|
||||
router.Use(logger.RequestLoggerMiddleware(logger.Default()))
|
||||
// 4. Error Handling (Terakhir, untuk menangkap error dari middleware di atasnya)
|
||||
router.Use(middleware.ErrorHandler())
|
||||
|
||||
// =============================================================================
|
||||
// INISIALISASI SERVIS & HANDLER
|
||||
// =============================================================================
|
||||
|
||||
// Initialize services with error handling
|
||||
authService := services.NewAuthService(cfg)
|
||||
if authService == nil {
|
||||
logger.Fatal("Failed to initialize auth service")
|
||||
}
|
||||
|
||||
// Initialize database service
|
||||
dbService := database.New(cfg)
|
||||
|
||||
// =============================================================================
|
||||
// HEALTH CHECK & SYSTEM ROUTES
|
||||
// SWAGGER DOCUMENTATION (Publik - TANPA SecurityHeaders)
|
||||
// =============================================================================
|
||||
// Route ini didefinisikan SEBELUM grup API agar tidak terkena middleware keamanan.
|
||||
router.GET("/swagger/*any", ginSwagger.WrapHandler(
|
||||
swaggerFiles.Handler,
|
||||
ginSwagger.DefaultModelsExpandDepth(-1),
|
||||
ginSwagger.DeepLinking(true),
|
||||
))
|
||||
|
||||
// =============================================================================
|
||||
// API GROUPS (Dengan Keamanan Ketat)
|
||||
// =============================================================================
|
||||
// Terapkan middleware keamanan dan validasi input HANYA ke grup API.
|
||||
// Ini adalah perubahan utama.
|
||||
apiGroup := router.Group("/api")
|
||||
apiGroup.Use(middleware.SecurityHeaders()) // <--- PINDAHKAN KE SINI
|
||||
apiGroup.Use(middleware.InputValidation(cfg.Security)) // <--- PINDAHKAN KE SINI
|
||||
|
||||
// --- HEALTH CHECK & SYSTEM ROUTES ---
|
||||
healthCheckHandler := healthcheckHandlers.NewHealthCheckHandler(dbService)
|
||||
sistem := router.Group("/api/sistem")
|
||||
sistem := apiGroup.Group("/sistem")
|
||||
{
|
||||
sistem.GET("/health", healthCheckHandler.CheckHealth)
|
||||
sistem.GET("/databases", func(c *gin.Context) {
|
||||
@@ -62,89 +89,93 @@ func RegisterRoutes(cfg *config.Config) *gin.Engine {
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SWAGGER DOCUMENTATION
|
||||
// =============================================================================
|
||||
|
||||
router.GET("/swagger/*any", ginSwagger.WrapHandler(
|
||||
swaggerFiles.Handler,
|
||||
ginSwagger.DefaultModelsExpandDepth(-1),
|
||||
ginSwagger.DeepLinking(true),
|
||||
))
|
||||
|
||||
// =============================================================================
|
||||
// API v1 GROUP
|
||||
// =============================================================================
|
||||
|
||||
v1 := router.Group("/api/v1")
|
||||
|
||||
// =============================================================================
|
||||
// PUBLIC ROUTES (No Authentication Required)
|
||||
// =============================================================================
|
||||
|
||||
// Authentication routes
|
||||
authHandler := authHandlers.NewAuthHandler(authService)
|
||||
tokenHandler := authHandlers.NewTokenHandler(authService)
|
||||
|
||||
// Basic auth routes
|
||||
v1.POST("/auth/login", authHandler.Login)
|
||||
v1.POST("/auth/register", authHandler.Register)
|
||||
v1.POST("/auth/refresh", authHandler.RefreshToken)
|
||||
|
||||
// Token generation routes
|
||||
v1.POST("/token/generate", tokenHandler.GenerateToken)
|
||||
v1.POST("/token/generate-direct", tokenHandler.GenerateTokenDirect)
|
||||
|
||||
// =============================================================================
|
||||
// PUBLISHED ROUTES
|
||||
|
||||
// Retribusi endpoints with
|
||||
retribusiHandler := retribusiHandlers.NewRetribusiHandler()
|
||||
retribusiGroup := v1.Group("/retribusi")
|
||||
// --- API v1 GROUP ---
|
||||
v1 := apiGroup.Group("/v1")
|
||||
{
|
||||
retribusiGroup.GET("", retribusiHandler.GetRetribusi)
|
||||
retribusiGroup.GET("/dynamic", retribusiHandler.GetRetribusiDynamic)
|
||||
retribusiGroup.GET("/search", retribusiHandler.SearchRetribusiAdvanced)
|
||||
retribusiGroup.GET("/id/:id", retribusiHandler.GetRetribusiByID)
|
||||
retribusiGroup.POST("", func(c *gin.Context) {
|
||||
retribusiHandler.CreateRetribusi(c)
|
||||
})
|
||||
// =============================================================================
|
||||
// PUBLIC ROUTES (No Authentication Required)
|
||||
// =============================================================================
|
||||
authHandler := authHandlers.NewAuthHandler(authService)
|
||||
tokenHandler := authHandlers.NewTokenHandler(authService)
|
||||
|
||||
retribusiGroup.PUT("/id/:id", func(c *gin.Context) {
|
||||
retribusiHandler.UpdateRetribusi(c)
|
||||
})
|
||||
v1.POST("/auth/login", authHandler.Login)
|
||||
v1.POST("/auth/register", authHandler.Register)
|
||||
v1.POST("/auth/refresh", authHandler.RefreshToken)
|
||||
|
||||
retribusiGroup.DELETE("/id/:id", func(c *gin.Context) {
|
||||
retribusiHandler.DeleteRetribusi(c)
|
||||
})
|
||||
v1.POST("/token/generate", tokenHandler.GenerateToken)
|
||||
v1.POST("/token/generate-direct", tokenHandler.GenerateTokenDirect)
|
||||
|
||||
retribusiHandler := retribusiHandlers.NewRetribusiHandler()
|
||||
retribusiGroup := v1.Group("/retribusi")
|
||||
{
|
||||
retribusiGroup.GET("", retribusiHandler.GetRetribusi)
|
||||
retribusiGroup.GET("/dynamic", retribusiHandler.GetRetribusiDynamic)
|
||||
retribusiGroup.GET("/id/:id", retribusiHandler.GetRetribusiByID)
|
||||
retribusiGroup.POST("", func(c *gin.Context) {
|
||||
retribusiHandler.CreateRetribusi(c)
|
||||
})
|
||||
retribusiGroup.PUT("/id/:id", func(c *gin.Context) {
|
||||
retribusiHandler.UpdateRetribusi(c)
|
||||
})
|
||||
retribusiGroup.DELETE("/id/:id", func(c *gin.Context) {
|
||||
retribusiHandler.DeleteRetribusi(c)
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PROTECTED ROUTES (Authentication Required)
|
||||
// =============================================================================
|
||||
protected := v1.Group("/")
|
||||
protected.Use(middleware.UnifiedAuthMiddleware(cfg, authService))
|
||||
|
||||
// farmasiObatHandler := farmasiObatHandlers.NewObatHandler()
|
||||
// protectedFarmasiGroup := protected.Group("/farmasi/obat")
|
||||
// {
|
||||
// protectedFarmasiGroup.GET("", farmasiObatHandler.GetObat)
|
||||
// protectedFarmasiGroup.GET("/kode/:kode", farmasiObatHandler.GetObatByID)
|
||||
// }
|
||||
|
||||
// protectedAuthGroup := protected.Group("/auth")
|
||||
// {
|
||||
// protectedAuthGroup.GET("/me", authHandler.Me)
|
||||
// }
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PROTECTED ROUTES (Authentication Required)
|
||||
// DEBUG ROUTES (Publik - Tanpa keamanan ketat)
|
||||
// =============================================================================
|
||||
router.GET("/debug/token", func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Header Authorization hilang"})
|
||||
return
|
||||
}
|
||||
|
||||
protected := v1.Group("/")
|
||||
protected.Use(middleware.ConfigurableAuthMiddleware(cfg))
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Format header harus Bearer {token}"})
|
||||
return
|
||||
}
|
||||
|
||||
// Protected retribusi endpoints (Authentication Required)
|
||||
// protectedRetribusiGroup := protected.Group("/retribusi")
|
||||
// {
|
||||
// protectedRetribusiGroup.GET("", retribusiHandler.GetRetribusi)
|
||||
// protectedRetribusiGroup.GET("/dynamic", retribusiHandler.GetRetribusiDynamic)
|
||||
// protectedRetribusiGroup.GET("/search", retribusiHandler.SearchRetribusiAdvanced)
|
||||
// protectedRetribusiGroup.GET("/id/:id", retribusiHandler.GetRetribusiByID)
|
||||
// protectedRetribusiGroup.POST("", func(c *gin.Context) {
|
||||
// retribusiHandler.CreateRetribusi(c)
|
||||
// })
|
||||
tokenString := parts[1]
|
||||
|
||||
// protectedRetribusiGroup.PUT("/id/:id", func(c *gin.Context) {
|
||||
// retribusiHandler.UpdateRetribusi(c)
|
||||
// })
|
||||
token, _, err := new(jwt.Parser).ParseUnverified(tokenString, jwt.MapClaims{})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Gagal parsing token: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// protectedRetribusiGroup.DELETE("/id/:id", func(c *gin.Context) {
|
||||
// retribusiHandler.DeleteRetribusi(c)
|
||||
// })
|
||||
// }
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Format claim tidak valid"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"header": token.Header,
|
||||
"claims": claims,
|
||||
})
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
+160
-33
@@ -1,9 +1,11 @@
|
||||
package services
|
||||
// services/auth/service.go
|
||||
package auth
|
||||
|
||||
import (
|
||||
"api-service/internal/config"
|
||||
models "api-service/internal/models/auth"
|
||||
"errors"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
@@ -12,8 +14,9 @@ import (
|
||||
|
||||
// AuthService handles authentication logic
|
||||
type AuthService struct {
|
||||
config *config.Config
|
||||
users map[string]*models.User // In-memory user store for demo
|
||||
config *config.Config
|
||||
users map[string]*models.User // In-memory user store for demo
|
||||
jwtSecret []byte
|
||||
}
|
||||
|
||||
// NewAuthService creates a new authentication service
|
||||
@@ -38,9 +41,16 @@ func NewAuthService(cfg *config.Config) *AuthService {
|
||||
Role: "user",
|
||||
}
|
||||
|
||||
// Get JWT secret from environment or use default
|
||||
jwtSecret := []byte(os.Getenv("JWT_SECRET"))
|
||||
if len(jwtSecret) == 0 {
|
||||
jwtSecret = []byte("your-secret-key-change-this-in-production")
|
||||
}
|
||||
|
||||
return &AuthService{
|
||||
config: cfg,
|
||||
users: users,
|
||||
config: cfg,
|
||||
users: users,
|
||||
jwtSecret: jwtSecret,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,65 +68,148 @@ func (s *AuthService) Login(username, password string) (*models.TokenResponse, e
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
token, err := s.generateToken(user)
|
||||
token, expiresIn, err := s.generateToken(user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Generate refresh token
|
||||
refreshToken, err := s.generateRefreshToken(user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &models.TokenResponse{
|
||||
AccessToken: token,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: 3600, // 1 hour
|
||||
AccessToken: token,
|
||||
RefreshToken: refreshToken,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: expiresIn,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// generateToken creates a new JWT token for the user
|
||||
func (s *AuthService) generateToken(user *models.User) (string, error) {
|
||||
// RefreshToken generates a new access token using a valid refresh token
|
||||
func (s *AuthService) RefreshToken(refreshTokenString string) (*models.TokenResponse, error) {
|
||||
// Parse and validate the refresh token
|
||||
token, err := jwt.Parse(refreshTokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, errors.New("unexpected signing method")
|
||||
}
|
||||
return s.jwtSecret, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid refresh token")
|
||||
}
|
||||
|
||||
if !token.Valid {
|
||||
return nil, errors.New("invalid refresh token")
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return nil, errors.New("invalid token claims")
|
||||
}
|
||||
|
||||
// Check if it's a refresh token
|
||||
tokenType, ok := claims["type"].(string)
|
||||
if !ok || tokenType != "refresh" {
|
||||
return nil, errors.New("not a refresh token")
|
||||
}
|
||||
|
||||
// Get user ID from claims
|
||||
userID, ok := claims["user_id"].(string)
|
||||
if !ok {
|
||||
return nil, errors.New("invalid user ID in token")
|
||||
}
|
||||
|
||||
// Find user
|
||||
var user *models.User
|
||||
for _, u := range s.users {
|
||||
if u.ID == userID {
|
||||
user = u
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
return nil, errors.New("user not found")
|
||||
}
|
||||
|
||||
// Generate new access token
|
||||
accessToken, expiresIn, err := s.generateToken(user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Generate new refresh token
|
||||
newRefreshToken, err := s.generateRefreshToken(user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &models.TokenResponse{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: newRefreshToken,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: expiresIn,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// generateToken creates a new JWT access token for the user
|
||||
func (s *AuthService) generateToken(user *models.User) (string, int64, error) {
|
||||
// Create claims
|
||||
now := time.Now()
|
||||
expiresAt := now.Add(time.Hour * 1) // 1 hour expiration
|
||||
|
||||
claims := jwt.MapClaims{
|
||||
"user_id": user.ID,
|
||||
"username": user.Username,
|
||||
"email": user.Email,
|
||||
"role": user.Role,
|
||||
"exp": time.Now().Add(time.Hour * 1).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
"type": "access",
|
||||
"exp": expiresAt.Unix(),
|
||||
"iat": now.Unix(),
|
||||
}
|
||||
|
||||
// Create token
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
|
||||
// Sign token with secret key
|
||||
secretKey := []byte(s.getJWTSecret())
|
||||
return token.SignedString(secretKey)
|
||||
tokenString, err := token.SignedString(s.jwtSecret)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
return tokenString, int64(time.Hour.Seconds()), nil
|
||||
}
|
||||
|
||||
// GenerateTokenForUser generates a JWT token for a specific user
|
||||
func (s *AuthService) GenerateTokenForUser(user *models.User) (string, error) {
|
||||
// generateRefreshToken creates a new JWT refresh token for the user
|
||||
func (s *AuthService) generateRefreshToken(user *models.User) (string, error) {
|
||||
// Create claims
|
||||
now := time.Now()
|
||||
expiresAt := now.Add(time.Hour * 24 * 7) // 7 days expiration
|
||||
|
||||
claims := jwt.MapClaims{
|
||||
"user_id": user.ID,
|
||||
"username": user.Username,
|
||||
"email": user.Email,
|
||||
"role": user.Role,
|
||||
"exp": time.Now().Add(time.Hour * 1).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
"user_id": user.ID,
|
||||
"type": "refresh",
|
||||
"exp": expiresAt.Unix(),
|
||||
"iat": now.Unix(),
|
||||
}
|
||||
|
||||
// Create token
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
|
||||
// Sign token with secret key
|
||||
secretKey := []byte(s.getJWTSecret())
|
||||
return token.SignedString(secretKey)
|
||||
return token.SignedString(s.jwtSecret)
|
||||
}
|
||||
|
||||
// ValidateToken validates the JWT token
|
||||
// ValidateToken validates the JWT access token
|
||||
func (s *AuthService) ValidateToken(tokenString string) (*models.JWTClaims, error) {
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, errors.New("unexpected signing method")
|
||||
}
|
||||
return []byte(s.getJWTSecret()), nil
|
||||
return s.jwtSecret, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
@@ -132,6 +225,12 @@ func (s *AuthService) ValidateToken(tokenString string) (*models.JWTClaims, erro
|
||||
return nil, errors.New("invalid claims")
|
||||
}
|
||||
|
||||
// Check if it's an access token
|
||||
tokenType, ok := claims["type"].(string)
|
||||
if !ok || tokenType != "access" {
|
||||
return nil, errors.New("not an access token")
|
||||
}
|
||||
|
||||
return &models.JWTClaims{
|
||||
UserID: claims["user_id"].(string),
|
||||
Username: claims["username"].(string),
|
||||
@@ -140,12 +239,6 @@ func (s *AuthService) ValidateToken(tokenString string) (*models.JWTClaims, erro
|
||||
}, nil
|
||||
}
|
||||
|
||||
// getJWTSecret returns the JWT secret key
|
||||
func (s *AuthService) getJWTSecret() string {
|
||||
// In production, this should come from environment variables
|
||||
return "your-secret-key-change-this-in-production"
|
||||
}
|
||||
|
||||
// RegisterUser registers a new user (for demo purposes)
|
||||
func (s *AuthService) RegisterUser(username, email, password, role string) error {
|
||||
if _, exists := s.users[username]; exists {
|
||||
@@ -167,3 +260,37 @@ func (s *AuthService) RegisterUser(username, email, password, role string) error
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateToken generates a JWT token for the given user data (public method)
|
||||
func (s *AuthService) GenerateToken(userID, username, email, role string) (*models.TokenResponse, error) {
|
||||
user := &models.User{
|
||||
ID: userID,
|
||||
Username: username,
|
||||
Email: email,
|
||||
Role: role,
|
||||
}
|
||||
|
||||
// Generate access token
|
||||
token, expiresIn, err := s.generateToken(user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Generate refresh token
|
||||
refreshToken, err := s.generateRefreshToken(user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &models.TokenResponse{
|
||||
AccessToken: token,
|
||||
RefreshToken: refreshToken,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: expiresIn,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GenerateTokenDirect generates a JWT token directly for the given user data (public method)
|
||||
func (s *AuthService) GenerateTokenDirect(userID, username, email, role string) (*models.TokenResponse, error) {
|
||||
return s.GenerateToken(userID, username, email, role)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,140 +2,228 @@ package validation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
queryUtils "api-service/internal/utils/query"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// ValidationConfig holds configuration for duplicate validation
|
||||
type ValidationConfig struct {
|
||||
TableName string
|
||||
IDColumn string
|
||||
StatusColumn string
|
||||
DateColumn string
|
||||
ActiveStatuses []string
|
||||
AdditionalFields map[string]interface{}
|
||||
// =============================================================================
|
||||
// DYNAMIC VALIDATION RULE
|
||||
// =============================================================================
|
||||
|
||||
// ValidationRule mendefinisikan aturan untuk memeriksa duplikat atau kondisi lain.
|
||||
// Struct ini membuat validator dapat digunakan kembali untuk tabel apa pun.
|
||||
type ValidationRule struct {
|
||||
// TableName adalah nama tabel yang akan diperiksa.
|
||||
TableName string
|
||||
|
||||
// UniqueColumns adalah daftar kolom yang, jika digabungkan, harus unik.
|
||||
// Contoh: []string{"email"} atau []string{"first_name", "last_name", "dob"}
|
||||
UniqueColumns []string
|
||||
|
||||
// Conditions adalah filter tambahan yang harus dipenuhi.
|
||||
// Ini sangat berguna untuk aturan bisnis, seperti "status != 'deleted'".
|
||||
// Gunakan queryUtils.DynamicFilter untuk fleksibilitas penuh.
|
||||
Conditions []queryUtils.DynamicFilter
|
||||
|
||||
// ExcludeIDColumn dan ExcludeIDValue digunakan untuk operasi UPDATE,
|
||||
// untuk memastikan bahwa record tidak membandingkan dirinya sendiri.
|
||||
ExcludeIDColumn string
|
||||
ExcludeIDValue interface{}
|
||||
}
|
||||
|
||||
// DuplicateValidator provides methods for validating duplicate entries
|
||||
type DuplicateValidator struct {
|
||||
db *sql.DB
|
||||
// NewUniqueFieldRule adalah helper untuk membuat aturan validasi unik untuk satu kolom.
|
||||
// Ini adalah cara cepat untuk membuat aturan yang paling umum.
|
||||
func NewUniqueFieldRule(tableName, uniqueColumn string, additionalConditions ...queryUtils.DynamicFilter) ValidationRule {
|
||||
return ValidationRule{
|
||||
TableName: tableName,
|
||||
UniqueColumns: []string{uniqueColumn},
|
||||
Conditions: additionalConditions,
|
||||
}
|
||||
}
|
||||
|
||||
// NewDuplicateValidator creates a new instance of DuplicateValidator
|
||||
func NewDuplicateValidator(db *sql.DB) *DuplicateValidator {
|
||||
return &DuplicateValidator{db: db}
|
||||
// =============================================================================
|
||||
// DYNAMIC VALIDATOR
|
||||
// =============================================================================
|
||||
|
||||
// DynamicValidator menyediakan metode untuk menjalankan validasi berdasarkan ValidationRule.
|
||||
// Ini sepenuhnya generik dan tidak terikat pada tabel atau model tertentu.
|
||||
type DynamicValidator struct {
|
||||
qb *queryUtils.QueryBuilder
|
||||
}
|
||||
|
||||
// ValidateDuplicate checks for duplicate entries based on the provided configuration
|
||||
func (dv *DuplicateValidator) ValidateDuplicate(ctx context.Context, config ValidationConfig, identifier interface{}) error {
|
||||
query := fmt.Sprintf(`
|
||||
SELECT COUNT(*)
|
||||
FROM %s
|
||||
WHERE %s = $1
|
||||
AND %s = ANY($2)
|
||||
AND DATE(%s) = CURRENT_DATE
|
||||
`, config.TableName, config.IDColumn, config.StatusColumn, config.DateColumn)
|
||||
|
||||
var count int
|
||||
err := dv.db.QueryRowContext(ctx, query, identifier, config.ActiveStatuses).Scan(&count)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check duplicate: %w", err)
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
return fmt.Errorf("data with ID %v already exists with active status today", identifier)
|
||||
}
|
||||
|
||||
return nil
|
||||
// NewDynamicValidator membuat instance DynamicValidator baru.
|
||||
func NewDynamicValidator(qb *queryUtils.QueryBuilder) *DynamicValidator {
|
||||
return &DynamicValidator{qb: qb}
|
||||
}
|
||||
|
||||
// ValidateDuplicateWithCustomFields checks for duplicates with additional custom fields
|
||||
func (dv *DuplicateValidator) ValidateDuplicateWithCustomFields(ctx context.Context, config ValidationConfig, fields map[string]interface{}) error {
|
||||
whereClause := fmt.Sprintf("%s = ANY($1) AND DATE(%s) = CURRENT_DATE", config.StatusColumn, config.DateColumn)
|
||||
args := []interface{}{config.ActiveStatuses}
|
||||
argIndex := 2
|
||||
|
||||
// Add additional field conditions
|
||||
for fieldName, fieldValue := range config.AdditionalFields {
|
||||
whereClause += fmt.Sprintf(" AND %s = $%d", fieldName, argIndex)
|
||||
args = append(args, fieldValue)
|
||||
argIndex++
|
||||
// Validate menjalankan validasi terhadap aturan yang diberikan.
|
||||
// `data` adalah map yang berisi nilai untuk kolom yang akan diperiksa (biasanya dari request body).
|
||||
// Mengembalikan `true` jika ada duplikat yang ditemukan (validasi gagal), `false` jika tidak ada duplikat (validasi berhasil).
|
||||
func (dv *DynamicValidator) Validate(ctx context.Context, db *sqlx.DB, rule ValidationRule, data map[string]interface{}) (bool, error) {
|
||||
if len(rule.UniqueColumns) == 0 {
|
||||
return false, fmt.Errorf("ValidationRule must have at least one UniqueColumn")
|
||||
}
|
||||
|
||||
// Add dynamic fields
|
||||
for fieldName, fieldValue := range fields {
|
||||
whereClause += fmt.Sprintf(" AND %s = $%d", fieldName, argIndex)
|
||||
args = append(args, fieldValue)
|
||||
argIndex++
|
||||
}
|
||||
// 1. Kumpulkan semua filter dari aturan
|
||||
var allFilters []queryUtils.DynamicFilter
|
||||
|
||||
query := fmt.Sprintf("SELECT COUNT(*) FROM %s WHERE %s", config.TableName, whereClause)
|
||||
// Tambahkan kondisi tambahan (misalnya, status != 'deleted')
|
||||
allFilters = append(allFilters, rule.Conditions...)
|
||||
|
||||
var count int
|
||||
err := dv.db.QueryRowContext(ctx, query, args...).Scan(&count)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check duplicate with custom fields: %w", err)
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
return fmt.Errorf("duplicate entry found with the specified criteria")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateOncePerDay ensures only one submission per day for a given identifier
|
||||
func (dv *DuplicateValidator) ValidateOncePerDay(ctx context.Context, tableName, idColumn, dateColumn string, identifier interface{}) error {
|
||||
query := fmt.Sprintf(`
|
||||
SELECT COUNT(*)
|
||||
FROM %s
|
||||
WHERE %s = $1
|
||||
AND DATE(%s) = CURRENT_DATE
|
||||
`, tableName, idColumn, dateColumn)
|
||||
|
||||
var count int
|
||||
err := dv.db.QueryRowContext(ctx, query, identifier).Scan(&count)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check daily submission: %w", err)
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
return fmt.Errorf("only one submission allowed per day for ID %v", identifier)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLastSubmissionTime returns the last submission time for a given identifier
|
||||
func (dv *DuplicateValidator) GetLastSubmissionTime(ctx context.Context, tableName, idColumn, dateColumn string, identifier interface{}) (*time.Time, error) {
|
||||
query := fmt.Sprintf(`
|
||||
SELECT %s
|
||||
FROM %s
|
||||
WHERE %s = $1
|
||||
ORDER BY %s DESC
|
||||
LIMIT 1
|
||||
`, dateColumn, tableName, idColumn, dateColumn)
|
||||
|
||||
var lastTime time.Time
|
||||
err := dv.db.QueryRowContext(ctx, query, identifier).Scan(&lastTime)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil // No previous submission
|
||||
// 2. Bangun filter untuk kolom unik berdasarkan data yang diberikan
|
||||
for _, colName := range rule.UniqueColumns {
|
||||
value, exists := data[colName]
|
||||
if !exists {
|
||||
// Jika data untuk kolom unik tidak ada, ini adalah kesalahan pemrograman.
|
||||
return false, fmt.Errorf("data for unique column '%s' not found in provided data map", colName)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get last submission time: %w", err)
|
||||
allFilters = append(allFilters, queryUtils.DynamicFilter{
|
||||
Column: colName,
|
||||
Operator: queryUtils.OpEqual,
|
||||
Value: value,
|
||||
})
|
||||
}
|
||||
|
||||
return &lastTime, nil
|
||||
// 3. Tambahkan filter pengecualian ID (untuk operasi UPDATE)
|
||||
if rule.ExcludeIDColumn != "" {
|
||||
allFilters = append(allFilters, queryUtils.DynamicFilter{
|
||||
Column: rule.ExcludeIDColumn,
|
||||
Operator: queryUtils.OpNotEqual,
|
||||
Value: rule.ExcludeIDValue,
|
||||
})
|
||||
}
|
||||
|
||||
// 4. Bangun dan eksekusi query untuk menghitung jumlah record yang cocok
|
||||
query := queryUtils.DynamicQuery{
|
||||
From: rule.TableName,
|
||||
Filters: []queryUtils.FilterGroup{{Filters: allFilters, LogicOp: "AND"}},
|
||||
}
|
||||
|
||||
count, err := dv.qb.ExecuteCount(ctx, db, query)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to execute validation query for table %s: %w", rule.TableName, err)
|
||||
}
|
||||
|
||||
// 5. Kembalikan hasil
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// DefaultRetribusiConfig returns default configuration for retribusi validation
|
||||
func DefaultRetribusiConfig() ValidationConfig {
|
||||
return ValidationConfig{
|
||||
TableName: "data_retribusi",
|
||||
IDColumn: "id",
|
||||
StatusColumn: "status",
|
||||
DateColumn: "date_created",
|
||||
ActiveStatuses: []string{"active", "draft"},
|
||||
}
|
||||
// =============================================================================
|
||||
// CONTOH PENGGUNAAN (UNTUK DITEMPATKAN DI HANDLER ANDA)
|
||||
// =============================================================================
|
||||
|
||||
/*
|
||||
// --- Cara Penggunaan di RetribusiHandler ---
|
||||
|
||||
// 1. Tambahkan DynamicValidator ke struct handler
|
||||
type RetribusiHandler struct {
|
||||
// ...
|
||||
validator *validation.DynamicValidator
|
||||
}
|
||||
|
||||
// 2. Inisialisasi di constructor
|
||||
func NewRetribusiHandler() *RetribusiHandler {
|
||||
qb := queryUtils.NewQueryBuilder(queryUtils.DBTypePostgreSQL).SetAllowedColumns(...)
|
||||
|
||||
return &RetribusiHandler{
|
||||
// ...
|
||||
validator: validation.NewDynamicValidator(qb),
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Gunakan di CreateRetribusi
|
||||
func (h *RetribusiHandler) CreateRetribusi(c *gin.Context) {
|
||||
var req retribusi.RetribusiCreateRequest
|
||||
// ... bind dan validasi request ...
|
||||
|
||||
// Siapkan aturan validasi: KodeTarif harus unik di antara record yang tidak dihapus.
|
||||
rule := validation.NewUniqueFieldRule(
|
||||
"data_retribusi", // Nama tabel
|
||||
"Kode_tarif", // Kolom yang harus unik
|
||||
queryUtils.DynamicFilter{ // Kondisi tambahan
|
||||
Column: "status",
|
||||
Operator: queryUtils.OpNotEqual,
|
||||
Value: "deleted",
|
||||
},
|
||||
)
|
||||
|
||||
// Siapkan data dari request untuk divalidasi
|
||||
dataToValidate := map[string]interface{}{
|
||||
"Kode_tarif": req.KodeTarif,
|
||||
}
|
||||
|
||||
// Eksekusi validasi
|
||||
isDuplicate, err := h.validator.Validate(ctx, dbConn, rule, dataToValidate)
|
||||
if err != nil {
|
||||
h.logAndRespondError(c, "Failed to validate Kode Tarif", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if isDuplicate {
|
||||
h.respondError(c, "Kode Tarif already exists", fmt.Errorf("duplicate Kode Tarif: %s", req.KodeTarif), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
// ... lanjutkan proses create ...
|
||||
}
|
||||
|
||||
// 4. Gunakan di UpdateRetribusi
|
||||
func (h *RetribusiHandler) UpdateRetribusi(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var req retribusi.RetribusiUpdateRequest
|
||||
// ... bind dan validasi request ...
|
||||
|
||||
// Siapkan aturan validasi: KodeTarif harus unik, kecuali untuk record dengan ID ini.
|
||||
rule := validation.ValidationRule{
|
||||
TableName: "data_retribusi",
|
||||
UniqueColumns: []string{"Kode_tarif"},
|
||||
Conditions: []queryUtils.DynamicFilter{
|
||||
{Column: "status", Operator: queryUtils.OpNotEqual, Value: "deleted"},
|
||||
},
|
||||
ExcludeIDColumn: "id", // Kecualikan berdasarkan kolom 'id'
|
||||
ExcludeIDValue: id, // ...dengan nilai ID dari parameter
|
||||
}
|
||||
|
||||
dataToValidate := map[string]interface{}{
|
||||
"Kode_tarif": req.KodeTarif,
|
||||
}
|
||||
|
||||
isDuplicate, err := h.validator.Validate(ctx, dbConn, rule, dataToValidate)
|
||||
if err != nil {
|
||||
h.logAndRespondError(c, "Failed to validate Kode Tarif", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if isDuplicate {
|
||||
h.respondError(c, "Kode Tarif already exists", fmt.Errorf("duplicate Kode Tarif: %s", req.KodeTarif), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
// ... lanjutkan proses update ...
|
||||
}
|
||||
|
||||
// --- Contoh Penggunaan untuk Kasus Lain ---
|
||||
|
||||
// Contoh: Validasi kombinasi unik untuk tabel 'users'
|
||||
// (email dan company_id harus unik bersama-sama)
|
||||
func (h *UserHandler) CreateUser(c *gin.Context) {
|
||||
// ...
|
||||
|
||||
rule := validation.ValidationRule{
|
||||
TableName: "users",
|
||||
UniqueColumns: []string{"email", "company_id"}, // Unik komposit
|
||||
}
|
||||
|
||||
dataToValidate := map[string]interface{}{
|
||||
"email": req.Email,
|
||||
"company_id": req.CompanyID,
|
||||
}
|
||||
|
||||
isDuplicate, err := h.validator.Validate(ctx, dbConn, rule, dataToValidate)
|
||||
// ... handle error dan duplicate
|
||||
}
|
||||
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user