updates on the otp updates on the customer app
This commit is contained in:
110
config/config.go
110
config/config.go
@@ -1,7 +1,13 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"doormile/utils"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
@@ -52,25 +58,93 @@ type Config struct {
|
||||
}
|
||||
|
||||
func Load() *Config {
|
||||
return &Config{
|
||||
Env: getEnv("ENV", "development"),
|
||||
Port: getEnv("APP_PORT", "8081"),
|
||||
DBName: getEnv("DB_NAME", "logistics"),
|
||||
DBUser: getEnv("DB_USER", "admin"),
|
||||
DBPassword: getEnv("DB_PASSWORD", "Package@321#"),
|
||||
DBPort: getEnv("DB_PORT", "5433"),
|
||||
DBHost: getEnv("DB_HOST", "127.0.0.1"),
|
||||
RedisHost: getEnv("REDIS_HOST", "127.0.0.1"),
|
||||
RedisPort: getEnv("REDIS_PORT", "6379"),
|
||||
RedisUser: getEnv("REDIS_USER", ""),
|
||||
RedisPassword: getEnv("REDIS_PASSWORD", ""),
|
||||
JWTSecret: getEnv("JWT_SECRET_KEY", "DoormileSuperSecretJWTKey2026!"),
|
||||
NatsURL: getEnv("NATS_URL", "nats://66.116.226.161:4223"),
|
||||
NatsUser: getEnv("NATS_USER", "doormile"),
|
||||
NatsPassword: getEnv("NATS_PASSWORD", "Package@321#"),
|
||||
AILayerBaseURL: getEnv("AI_LAYER_BASE_URL", "https://routemate.workolik.com"),
|
||||
cfg := load()
|
||||
cfg.hardenSecrets()
|
||||
return cfg
|
||||
}
|
||||
|
||||
RouteOptimizerURL: getEnv("ROUTE_OPTIMIZER_URL", "https://routes.workolik.com"),
|
||||
// IsProduction reports whether this process is running as production. Used by
|
||||
// the guards that must behave differently there — a fixed OTP, a missing JWT
|
||||
// secret — rather than scattering string comparisons.
|
||||
func (c *Config) IsProduction() bool {
|
||||
return strings.EqualFold(strings.TrimSpace(c.Env), "production")
|
||||
}
|
||||
|
||||
// hardenSecrets refuses to let the service run on a guessable signing key.
|
||||
//
|
||||
// JWT_SECRET_KEY used to default to a literal in this file. Anyone holding the
|
||||
// repository could mint a token for any user id and any role, against any
|
||||
// deployment that had not overridden it — which is the whole authorisation
|
||||
// model, given away by a git clone.
|
||||
//
|
||||
// In production an unset secret is fatal: booting with a known key is worse
|
||||
// than not booting, because nothing external shows that anything is wrong.
|
||||
// Anywhere else it becomes a random per-process key, so local development
|
||||
// works without configuration while tokens stop surviving a restart and can
|
||||
// never be valid anywhere but this process.
|
||||
func (c *Config) hardenSecrets() {
|
||||
if strings.TrimSpace(c.JWTSecret) != "" {
|
||||
return
|
||||
}
|
||||
// In production an absent secret is left absent, so Validate can refuse the
|
||||
// boot with a clear message. Generating one here would be worse than the
|
||||
// old default: every restart would invalidate every live session, and
|
||||
// nothing would say why.
|
||||
if c.IsProduction() {
|
||||
return
|
||||
}
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
// Leave it empty; Validate turns this into a refusal to start.
|
||||
return
|
||||
}
|
||||
c.JWTSecret = hex.EncodeToString(b)
|
||||
utils.Warn("JWT_SECRET_KEY is not set — generated an ephemeral key for this process only. " +
|
||||
"Tokens will not survive a restart. Set JWT_SECRET_KEY for a stable local session.")
|
||||
}
|
||||
|
||||
// Validate reports configuration that must prevent the service from starting.
|
||||
// Called by main; kept separate from Load so that loading stays free of side
|
||||
// effects and the package remains testable.
|
||||
func (c *Config) Validate() error {
|
||||
if strings.TrimSpace(c.JWTSecret) == "" {
|
||||
return fmt.Errorf("JWT_SECRET_KEY is not set (ENV=%s): refusing to start, because "+
|
||||
"booting on a default or empty signing key lets anyone holding this repository "+
|
||||
"mint a valid token for any account", c.Env)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func load() *Config {
|
||||
return &Config{
|
||||
Env: getEnv("ENV", "development"),
|
||||
Port: getEnv("APP_PORT", "8081"),
|
||||
DBName: getEnv("DB_NAME", "logistics"),
|
||||
DBUser: getEnv("DB_USER", "admin"),
|
||||
DBPassword: getEnv("DB_PASSWORD", ""),
|
||||
DBPort: getEnv("DB_PORT", "5433"),
|
||||
DBHost: getEnv("DB_HOST", "127.0.0.1"),
|
||||
RedisHost: getEnv("REDIS_HOST", "127.0.0.1"),
|
||||
RedisPort: getEnv("REDIS_PORT", "6379"),
|
||||
RedisUser: getEnv("REDIS_USER", ""),
|
||||
RedisPassword: getEnv("REDIS_PASSWORD", ""),
|
||||
// No default. See hardenSecrets below — an unset secret is either a
|
||||
// refusal to boot or an ephemeral per-process key, never a shared one
|
||||
// baked into the source.
|
||||
JWTSecret: getEnv("JWT_SECRET_KEY", ""),
|
||||
|
||||
// These defaulted to the real production hosts and credentials, which
|
||||
// meant `go run .` on a laptop silently joined the live NATS stream and
|
||||
// competed with the production workers for the same durable consumer.
|
||||
// Empty now: InitNATS skips connecting, routing.BaseURL == "" disables
|
||||
// sequencing, and the AI layer falls back to legacy scoring. Fail
|
||||
// closed, so reaching production is something you opt into.
|
||||
NatsURL: getEnv("NATS_URL", ""),
|
||||
NatsUser: getEnv("NATS_USER", ""),
|
||||
NatsPassword: getEnv("NATS_PASSWORD", ""),
|
||||
AILayerBaseURL: getEnv("AI_LAYER_BASE_URL", ""),
|
||||
|
||||
RouteOptimizerURL: getEnv("ROUTE_OPTIMIZER_URL", ""),
|
||||
GeocoderURL: getEnv("GEOCODER_URL", "https://nominatim.openstreetmap.org"),
|
||||
GeocoderEmail: getEnv("GEOCODER_EMAIL", ""),
|
||||
TrustedProxies: getEnv("TRUSTED_PROXIES", ""),
|
||||
|
||||
142
config/secrets_test.go
Normal file
142
config/secrets_test.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// DM-06: config.go used to default JWT_SECRET_KEY, the NATS URL and its
|
||||
// credentials, and the AI/optimiser hosts to the REAL production values. Two
|
||||
// consequences: anyone holding the repository could mint a valid token for any
|
||||
// account against a deployment that had not overridden the secret, and any
|
||||
// local run silently joined the live NATS stream.
|
||||
|
||||
// The literals that must never come back. Written out so a revert is a test
|
||||
// failure rather than something noticed in review.
|
||||
func TestProductionValuesAreNotDefaults(t *testing.T) {
|
||||
for _, key := range []string{
|
||||
"JWT_SECRET_KEY", "NATS_URL", "NATS_USER", "NATS_PASSWORD",
|
||||
"AI_LAYER_BASE_URL", "ROUTE_OPTIMIZER_URL", "DB_PASSWORD",
|
||||
} {
|
||||
setEnv(t, key, "")
|
||||
}
|
||||
setEnv(t, "ENV", "development")
|
||||
|
||||
cfg := Load()
|
||||
|
||||
banned := map[string]string{
|
||||
"NatsURL": cfg.NatsURL,
|
||||
"NatsUser": cfg.NatsUser,
|
||||
"NatsPassword": cfg.NatsPassword,
|
||||
"AILayerBaseURL": cfg.AILayerBaseURL,
|
||||
"RouteOptimizerURL": cfg.RouteOptimizerURL,
|
||||
"DBPassword": cfg.DBPassword,
|
||||
}
|
||||
for field, got := range banned {
|
||||
if got != "" {
|
||||
t.Errorf("%s defaulted to %q — production values must not be defaults", field, got)
|
||||
}
|
||||
}
|
||||
|
||||
// The old hardcoded secret must not be what we sign with.
|
||||
if cfg.JWTSecret == "DoormileSuperSecretJWTKey2026!" {
|
||||
t.Error("JWTSecret fell back to the literal that used to be in config.go")
|
||||
}
|
||||
}
|
||||
|
||||
// With no secret configured outside production the service still runs, but on
|
||||
// a key that exists only for this process.
|
||||
func TestUnsetSecretOutsideProductionIsEphemeralNotShared(t *testing.T) {
|
||||
setEnv(t, "JWT_SECRET_KEY", "")
|
||||
setEnv(t, "ENV", "development")
|
||||
|
||||
first := Load().JWTSecret
|
||||
second := Load().JWTSecret
|
||||
|
||||
if first == "" || second == "" {
|
||||
t.Fatal("an unset secret produced an empty signing key; tokens would be forgeable")
|
||||
}
|
||||
if first == second {
|
||||
t.Error("two loads produced the same generated key — it is not ephemeral")
|
||||
}
|
||||
if len(first) < 32 {
|
||||
t.Errorf("generated key is %d chars, too short to be a signing key", len(first))
|
||||
}
|
||||
}
|
||||
|
||||
// In production an absent secret is NOT quietly replaced — it is left absent so
|
||||
// Validate can refuse the boot with a message that says why. Silently
|
||||
// generating one would invalidate every live session on each restart.
|
||||
func TestProductionRefusesToStartWithoutASecret(t *testing.T) {
|
||||
setEnv(t, "JWT_SECRET_KEY", "")
|
||||
setEnv(t, "ENV", "production")
|
||||
|
||||
cfg := Load()
|
||||
if cfg.JWTSecret != "" {
|
||||
t.Errorf("production generated a secret (%q); it must stay empty so Validate fails", cfg.JWTSecret)
|
||||
}
|
||||
err := cfg.Validate()
|
||||
if err == nil {
|
||||
t.Fatal("Validate accepted an empty JWT secret in production")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "JWT_SECRET_KEY") {
|
||||
t.Errorf("Validate error does not name the variable: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Outside production the ephemeral key is enough to pass validation, so local
|
||||
// development needs no configuration at all.
|
||||
func TestValidatePassesOutsideProductionWithNoSecret(t *testing.T) {
|
||||
setEnv(t, "JWT_SECRET_KEY", "")
|
||||
setEnv(t, "ENV", "development")
|
||||
|
||||
if err := Load().Validate(); err != nil {
|
||||
t.Errorf("development should boot without a configured secret: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A configured secret always validates, production or not.
|
||||
func TestValidatePassesWithAConfiguredSecret(t *testing.T) {
|
||||
setEnv(t, "JWT_SECRET_KEY", "a-real-configured-secret")
|
||||
setEnv(t, "ENV", "production")
|
||||
|
||||
if err := Load().Validate(); err != nil {
|
||||
t.Errorf("a configured secret must validate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// An explicitly configured secret is always used verbatim.
|
||||
func TestConfiguredSecretIsUsedVerbatim(t *testing.T) {
|
||||
setEnv(t, "JWT_SECRET_KEY", "a-real-configured-secret")
|
||||
setEnv(t, "ENV", "production")
|
||||
|
||||
if got := Load().JWTSecret; got != "a-real-configured-secret" {
|
||||
t.Errorf("JWTSecret = %q, want the configured value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsProductionIsCaseAndSpaceInsensitive(t *testing.T) {
|
||||
setEnv(t, "JWT_SECRET_KEY", "x")
|
||||
for _, env := range []string{"production", "Production", "PRODUCTION", " production "} {
|
||||
setEnv(t, "ENV", env)
|
||||
if !Load().IsProduction() {
|
||||
t.Errorf("ENV=%q was not treated as production", env)
|
||||
}
|
||||
}
|
||||
for _, env := range []string{"development", "staging", "test", ""} {
|
||||
setEnv(t, "ENV", env)
|
||||
if Load().IsProduction() {
|
||||
t.Errorf("ENV=%q was treated as production", env)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The geocoder is a PUBLIC service, not a Doormile host, so it keeps its
|
||||
// default — removing it would break place search for no security gain.
|
||||
func TestGeocoderKeepsItsPublicDefault(t *testing.T) {
|
||||
setEnv(t, "JWT_SECRET_KEY", "x")
|
||||
setEnv(t, "GEOCODER_URL", "")
|
||||
if got := Load().GeocoderURL; !strings.Contains(got, "nominatim") {
|
||||
t.Errorf("GeocoderURL = %q, want the public Nominatim default", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user