diff --git a/.env.example b/.env.example index f49e4e2e6..fa4dfd441 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,7 @@ HTTP_TLS_KEY_FILE= # Logger LOG_LEVEL=info +GIN_MODE=release # Remote Secret Store (Vault) SECRET_ADDR=http://localhost:8200 @@ -32,6 +33,12 @@ SECRET_TOKEN= DB_POOL_MAX=2 DB_PROVIDER= DB_URL= +POSTGRES_USER=postgresadmin +POSTGRES_PASSWORD= +POSTGRES_DB=rpsdb +MONGO_INITDB_DATABASE=consoledb +MONGO_INITDB_ROOT_USERNAME=mongoadmin +MONGO_INITDB_ROOT_PASSWORD= # EA EA_URL=http://localhost:8000 @@ -43,12 +50,13 @@ AUTH_DISABLED=false AUTH_ADMIN_USERNAME=standalone # AUTH_ADMIN_PASSWORD: If not set, a random password is generated AUTH_ADMIN_PASSWORD= -AUTH_JWT_KEY=your_secret_jwt_key +# AUTH_JWT_KEY: If unset, a strong key is generated and saved on first run. +# If set, the value is used as-is (must be non-empty; set to empty will cause startup failure). +AUTH_JWT_KEY= AUTH_JWT_EXPIRATION=24h AUTH_REDIRECTION_JWT_EXPIRATION=5m AUTH_CLIENT_ID= -AUTH_ISSUER=GIN_MODE=release -# DB_URL=postgres://postgresadmin:admin123@localhost:5432/rpsdb +# DB_URL=postgres://:@localhost:5432/ # OAUTH CONFIGURATION AUTH_CLIENT_ID= # ex. "https://login.microsoftonline.com//v2.0 for Azure Entra -- used for discovery diff --git a/config/config.go b/config/config.go index 74f238dfd..dd7f4911d 100644 --- a/config/config.go +++ b/config/config.go @@ -1,11 +1,15 @@ package config import ( + "crypto/rand" + "encoding/hex" "errors" "flag" + "log" "net" "os" "path/filepath" + "strings" "time" "github.com/ilyakaznacheev/cleanenv" @@ -17,7 +21,15 @@ var ConsoleConfig *Config // TrayMode indicates whether to run with system tray UI. var TrayMode bool -const defaultHost = "localhost" +const ( + defaultHost = "localhost" + hs256RecommendedMinKeyBytes = 32 +) + +// ErrEmptyJWTKeyEnv is returned when AUTH_JWT_KEY is set in the environment but +// has an empty or whitespace-only value. Operators must either provide a non-empty +// key or leave the variable unset so Console can auto-generate one. +var ErrEmptyJWTKeyEnv = errors.New("AUTH_JWT_KEY is set but empty; provide a non-empty key or unset the variable to auto-generate") type ( // Config -. @@ -94,7 +106,7 @@ type ( Disabled bool `yaml:"disabled" env:"AUTH_DISABLED"` AdminUsername string `yaml:"adminUsername" env:"AUTH_ADMIN_USERNAME"` AdminPassword string `yaml:"adminPassword" env:"AUTH_ADMIN_PASSWORD"` - JWTKey string `env-required:"true" yaml:"jwtKey" env:"AUTH_JWT_KEY"` + JWTKey string `yaml:"jwtKey" env:"AUTH_JWT_KEY"` JWTExpiration time.Duration `yaml:"jwtExpiration" env:"AUTH_JWT_EXPIRATION"` RedirectionJWTExpiration time.Duration `yaml:"redirectionJWTExpiration" env:"AUTH_REDIRECTION_JWT_EXPIRATION"` ClientID string `yaml:"clientId" env:"AUTH_CLIENT_ID"` @@ -187,7 +199,7 @@ func defaultConfig() *Config { Auth: Auth{ AdminUsername: "standalone", AdminPassword: "", // Generated and stored in config on first run if not provided - JWTKey: "your_secret_jwt_key", + JWTKey: "", // Generated and stored in config on first run if not provided JWTExpiration: 24 * time.Hour, RedirectionJWTExpiration: 5 * time.Minute, // OAUTH CONFIG, if provided will not use basic auth @@ -236,17 +248,42 @@ func resolveConfigPath(configPathFlag string) (string, error) { func readOrInitConfig(configPath string, cfg *Config) error { err := cleanenv.ReadConfig(configPath, cfg) if err == nil { - return nil + if cfg.JWTKey != "" { + return nil + } + + if hasNonEmptyJWTKeyEnv() { + return nil + } + + jwtKey, genErr := generateAndSetJWTKey(cfg) + if genErr != nil { + return genErr + } + + return saveJWTKey(configPath, jwtKey) } var pathErr *os.PathError if errors.As(err, &pathErr) { + if !hasNonEmptyJWTKeyEnv() { + if _, genErr := generateAndSetJWTKey(cfg); genErr != nil { + return genErr + } + } + return writeConfig(configPath, cfg) } return err } +func hasNonEmptyJWTKeyEnv() bool { + jwtKeyEnv, isSetByEnv := os.LookupEnv("AUTH_JWT_KEY") + + return isSetByEnv && strings.TrimSpace(jwtKeyEnv) != "" +} + // writeConfig serializes cfg to configPath, creating the parent directory if needed. func writeConfig(configPath string, cfg *Config) error { configDir := filepath.Dir(configPath) @@ -296,6 +333,69 @@ func SaveAdminPassword(adminPassword string) error { return writeConfig(configPath, fileCfg) } +func saveJWTKey(configPath, jwtKey string) error { + data, err := os.ReadFile(configPath) + if err != nil { + return err + } + + fileCfg := defaultConfig() + if err := yaml.Unmarshal(data, fileCfg); err != nil { + return err + } + + fileCfg.JWTKey = jwtKey + + return writeConfig(configPath, fileCfg) +} + +func generateJWTKey() (string, error) { + b := make([]byte, hs256RecommendedMinKeyBytes) + if _, err := rand.Read(b); err != nil { + return "", err + } + + return hex.EncodeToString(b), nil +} + +func generateAndSetJWTKey(cfg *Config) (string, error) { + jwtKey, err := generateJWTKey() + if err != nil { + return "", err + } + + cfg.JWTKey = jwtKey + + return jwtKey, nil +} + +func warnIfWeakJWTKey(cfg *Config) { + keyLen := len([]byte(cfg.JWTKey)) + if keyLen < hs256RecommendedMinKeyBytes { + log.Printf("WARNING: auth.jwtKey looks too short (%d bytes). Please use at least %d bytes for a strong key.", keyLen, hs256RecommendedMinKeyBytes) + } +} + +func ensureJWTKeyNotEmpty(cfg *Config, configPath string) error { + if strings.TrimSpace(cfg.JWTKey) != "" { + return nil + } + + // If AUTH_JWT_KEY is explicitly set in the environment but empty, fail fast. + // Operators who opt in to env-driven config must supply a valid value. + jwtKeyEnv, isSetByEnv := os.LookupEnv("AUTH_JWT_KEY") + if isSetByEnv && strings.TrimSpace(jwtKeyEnv) == "" { + return ErrEmptyJWTKeyEnv + } + + jwtKey, err := generateAndSetJWTKey(cfg) + if err != nil { + return err + } + + return saveJWTKey(configPath, jwtKey) +} + // NewConfig returns app config. func NewConfig() (*Config, error) { // set defaults @@ -329,5 +429,11 @@ func NewConfig() (*Config, error) { return nil, err } + if err := ensureJWTKeyNotEmpty(ConsoleConfig, configPath); err != nil { + return nil, err + } + + warnIfWeakJWTKey(ConsoleConfig) + return ConsoleConfig, nil } diff --git a/config/config.yml b/config/config.yml index 782bd309e..b3713c03c 100644 --- a/config/config.yml +++ b/config/config.yml @@ -39,7 +39,7 @@ auth: disabled: false adminUsername: standalone adminPassword: - jwtKey: your_secret_jwt_key + jwtKey: jwtExpiration: 24h0m0s redirectionJWTExpiration: 5m0s clientId: "" diff --git a/config/config_test.go b/config/config_test.go index 24e4360e7..59d795f1b 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -1,10 +1,18 @@ package config import ( + "bytes" + "encoding/hex" + "flag" + "log" "os" + "path/filepath" + "sync" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v2" ) func clearEnv() { @@ -13,6 +21,25 @@ func clearEnv() { os.Unsetenv("LOG_LEVEL") os.Unsetenv("DB_POOL_MAX") os.Unsetenv("DB_URL") + os.Unsetenv("AUTH_JWT_KEY") +} + +var logOutputMu sync.Mutex + +func captureWarnIfWeakJWTKeyOutput(run func()) string { + logOutputMu.Lock() + defer logOutputMu.Unlock() + + var buf bytes.Buffer + + originalWriter := log.Writer() + + log.SetOutput(&buf) + defer log.SetOutput(originalWriter) + + run() + + return buf.String() } func TestNewConfig_Defaults(t *testing.T) { //nolint:paralleltest // cannot have simultaneous tests modifying environment variables @@ -107,3 +134,272 @@ postgres: assert.Equal(t, 10, cfg.PoolMax) assert.Equal(t, "postgres://envuser:envpassword@localhost:5432/envdb", cfg.DB.URL) } + +//nolint:paralleltest // mutates process environment variable AUTH_JWT_KEY +func TestReadOrInitConfig_GeneratesJWTKeyForNewConfig(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.yml") + + originalJWTKey, hadJWTKey := os.LookupEnv("AUTH_JWT_KEY") + err := os.Unsetenv("AUTH_JWT_KEY") + require.NoError(t, err) + + t.Cleanup(func() { + if hadJWTKey { + require.NoError(t, os.Setenv("AUTH_JWT_KEY", originalJWTKey)) + } else { + require.NoError(t, os.Unsetenv("AUTH_JWT_KEY")) + } + }) + + cfg := defaultConfig() + err = readOrInitConfig(configPath, cfg) + assert.NoError(t, err) + assert.NotEmpty(t, cfg.JWTKey) + assert.Len(t, cfg.JWTKey, 64) + + data, err := os.ReadFile(configPath) + assert.NoError(t, err) + + fileCfg := defaultConfig() + err = yaml.Unmarshal(data, fileCfg) + assert.NoError(t, err) + assert.Equal(t, cfg.JWTKey, fileCfg.JWTKey) +} + +//nolint:paralleltest // mutates process environment variable AUTH_JWT_KEY +func TestReadOrInitConfig_GeneratesJWTKeyForExistingEmptyConfig(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.yml") + + originalJWTKey, hadJWTKey := os.LookupEnv("AUTH_JWT_KEY") + err := os.Unsetenv("AUTH_JWT_KEY") + require.NoError(t, err) + + t.Cleanup(func() { + if hadJWTKey { + require.NoError(t, os.Setenv("AUTH_JWT_KEY", originalJWTKey)) + } else { + require.NoError(t, os.Unsetenv("AUTH_JWT_KEY")) + } + }) + + cfg := defaultConfig() + cfg.JWTKey = "" + err = writeConfig(configPath, cfg) + assert.NoError(t, err) + + readCfg := defaultConfig() + err = readOrInitConfig(configPath, readCfg) + assert.NoError(t, err) + assert.NotEmpty(t, readCfg.JWTKey) + assert.Len(t, readCfg.JWTKey, 64) + + data, err := os.ReadFile(configPath) + assert.NoError(t, err) + + fileCfg := defaultConfig() + err = yaml.Unmarshal(data, fileCfg) + assert.NoError(t, err) + assert.Equal(t, readCfg.JWTKey, fileCfg.JWTKey) +} + +//nolint:paralleltest // mutates process environment variable AUTH_JWT_KEY +func TestReadOrInitConfig_DoesNotMutateExistingNonEmptyJWTKey(t *testing.T) { + originalJWTKey, hadJWTKey := os.LookupEnv("AUTH_JWT_KEY") + err := os.Unsetenv("AUTH_JWT_KEY") + require.NoError(t, err) + + t.Cleanup(func() { + if hadJWTKey { + require.NoError(t, os.Setenv("AUTH_JWT_KEY", originalJWTKey)) + } else { + require.NoError(t, os.Unsetenv("AUTH_JWT_KEY")) + } + }) + + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.yml") + + cfg := defaultConfig() + cfg.JWTKey = "existing-jwt-key" + err = writeConfig(configPath, cfg) + assert.NoError(t, err) + + readCfg := defaultConfig() + err = readOrInitConfig(configPath, readCfg) + assert.NoError(t, err) + assert.Equal(t, "existing-jwt-key", readCfg.JWTKey) + + var data []byte + + data, err = os.ReadFile(configPath) + assert.NoError(t, err) + + fileCfg := defaultConfig() + err = yaml.Unmarshal(data, fileCfg) + assert.NoError(t, err) + assert.Equal(t, "existing-jwt-key", fileCfg.JWTKey) +} + +//nolint:paralleltest // mutates process environment variable AUTH_JWT_KEY +func TestReadOrInitConfig_GeneratesJWTKeyWhenEnvIsEmpty(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.yml") + + originalJWTKey, hadJWTKey := os.LookupEnv("AUTH_JWT_KEY") + err := os.Setenv("AUTH_JWT_KEY", "") + require.NoError(t, err) + + t.Cleanup(func() { + if hadJWTKey { + require.NoError(t, os.Setenv("AUTH_JWT_KEY", originalJWTKey)) + } else { + require.NoError(t, os.Unsetenv("AUTH_JWT_KEY")) + } + }) + + cfg := defaultConfig() + err = readOrInitConfig(configPath, cfg) + assert.NoError(t, err) + assert.NotEmpty(t, cfg.JWTKey) + assert.Len(t, cfg.JWTKey, 64) + + data, readErr := os.ReadFile(configPath) + assert.NoError(t, readErr) + + fileCfg := defaultConfig() + err = yaml.Unmarshal(data, fileCfg) + assert.NoError(t, err) + assert.Equal(t, cfg.JWTKey, fileCfg.JWTKey) +} + +func TestGenerateJWTKey_ReturnsHexEncoded256BitKey(t *testing.T) { + t.Parallel() + + key, err := generateJWTKey() + assert.NoError(t, err) + assert.Len(t, key, 64) + + decoded, err := hex.DecodeString(key) + assert.NoError(t, err) + assert.Len(t, decoded, 32) +} + +func TestResolveConfigPath_UsesProvidedFlag(t *testing.T) { + t.Parallel() + + path, err := resolveConfigPath("/tmp/custom-config.yml") + assert.NoError(t, err) + assert.Equal(t, "/tmp/custom-config.yml", path) +} + +func TestNewConfig_EnvOverridesFileJWTKey(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.yml") + + cfg := defaultConfig() + cfg.JWTKey = "file-key" + err := writeConfig(configPath, cfg) + assert.NoError(t, err) + + origArgs := os.Args + origFlagSet := flag.CommandLine + os.Args = []string{origArgs[0], "-config", configPath} + flag.CommandLine = flag.NewFlagSet(origArgs[0], flag.ContinueOnError) + + t.Cleanup(func() { + os.Args = origArgs + flag.CommandLine = origFlagSet + }) + + t.Setenv("AUTH_JWT_KEY", "env-key") + + loadedCfg, err := NewConfig() + assert.NoError(t, err) + assert.Equal(t, "env-key", loadedCfg.JWTKey) +} + +func TestWarnIfWeakJWTKey_LogsWarning(t *testing.T) { + t.Parallel() + + cfg := defaultConfig() + cfg.JWTKey = "short" + + output := captureWarnIfWeakJWTKeyOutput(func() { + warnIfWeakJWTKey(cfg) + }) + + assert.Contains(t, output, "at least 32 bytes") +} + +func TestWarnIfWeakJWTKey_DoesNotLogForStrongKey(t *testing.T) { + t.Parallel() + + cfg := defaultConfig() + + strongKey, err := generateJWTKey() + assert.NoError(t, err) + + cfg.JWTKey = strongKey + + output := captureWarnIfWeakJWTKeyOutput(func() { + warnIfWeakJWTKey(cfg) + }) + + assert.Empty(t, output) +} + +func TestNewConfig_EmptyEnvJWTKeyFailsStartup(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.yml") + + cfg := defaultConfig() + cfg.JWTKey = "" + err := writeConfig(configPath, cfg) + assert.NoError(t, err) + + origArgs := os.Args + origFlagSet := flag.CommandLine + os.Args = []string{origArgs[0], "-config", configPath} + flag.CommandLine = flag.NewFlagSet(origArgs[0], flag.ContinueOnError) + + t.Cleanup(func() { + os.Args = origArgs + flag.CommandLine = origFlagSet + }) + + t.Setenv("AUTH_JWT_KEY", "") + + _, err = NewConfig() + require.ErrorIs(t, err, ErrEmptyJWTKeyEnv) +} + +func TestNewConfig_EnvWeakJWTKeyStillWarns(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.yml") + + cfg := defaultConfig() + cfg.JWTKey = "" + err := writeConfig(configPath, cfg) + assert.NoError(t, err) + + origArgs := os.Args + origFlagSet := flag.CommandLine + os.Args = []string{origArgs[0], "-config", configPath} + flag.CommandLine = flag.NewFlagSet(origArgs[0], flag.ContinueOnError) + + t.Cleanup(func() { + os.Args = origArgs + flag.CommandLine = origFlagSet + }) + + t.Setenv("AUTH_JWT_KEY", "short") + + output := captureWarnIfWeakJWTKeyOutput(func() { + _, newCfgErr := NewConfig() + assert.NoError(t, newCfgErr) + }) + + assert.Contains(t, output, "at least 32 bytes") +} diff --git a/docker-compose.yml b/docker-compose.yml index bba970356..6f024d7c7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,9 +19,9 @@ services: volumes: - pg-data:/var/lib/postgresql environment: - POSTGRES_USER: "postgresadmin" - POSTGRES_PASSWORD: "admin123" - POSTGRES_DB: "rpsdb" + POSTGRES_USER: "${POSTGRES_USER:-postgresadmin}" + POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-}" + POSTGRES_DB: "${POSTGRES_DB:-rpsdb}" ports: - 5432:5432 mongo: @@ -34,9 +34,9 @@ services: volumes: - mongo-data:/data/db environment: - MONGO_INITDB_DATABASE: consoledb - MONGO_INITDB_ROOT_USERNAME: "mongoadmin" - MONGO_INITDB_ROOT_PASSWORD: "admin123" + MONGO_INITDB_DATABASE: "${MONGO_INITDB_DATABASE:-consoledb}" + MONGO_INITDB_ROOT_USERNAME: "${MONGO_INITDB_ROOT_USERNAME:-mongoadmin}" + MONGO_INITDB_ROOT_PASSWORD: "${MONGO_INITDB_ROOT_PASSWORD:-}" ports: - "127.0.0.1:27017:27017" healthcheck: @@ -45,9 +45,9 @@ services: - mongosh - --quiet - -u - - "mongoadmin" + - "${MONGO_INITDB_ROOT_USERNAME:-mongoadmin}" - -p - - "admin123" + - "${MONGO_INITDB_ROOT_PASSWORD:-}" - --authenticationDatabase - admin - --eval @@ -65,12 +65,12 @@ services: env_file: - .env environment: - APP_ENCRYPTION_KEY: "Jf3Q2nXJ+GZzN1dbVQms0wbB4+i/5PjL" # test-only + APP_ENCRYPTION_KEY: "${APP_ENCRYPTION_KEY:-}" HTTP_HOST: "" HTTP_TLS_ENABLED: "false" GIN_MODE: "debug" DB_PROVIDER: "${DB_PROVIDER:-postgres}" - DB_URL: "${DB_URL:-postgres://postgresadmin:admin123@postgres:5432/rpsdb}" + DB_URL: "${DB_URL:-postgres://${POSTGRES_USER:-postgresadmin}:${POSTGRES_PASSWORD:-}@postgres:5432/${POSTGRES_DB:-rpsdb}}" AUTH_DISABLED: true ports: - 8181:8181