From 2309ef3dcb6279d4d0e78de39ec79c2cbbc63e06 Mon Sep 17 00:00:00 2001 From: nhaydel Date: Sat, 18 Mar 2023 13:32:17 -0500 Subject: [PATCH 1/4] Add basic auth, and default load balancer --- auth/auth.go | 7 +- auth/basic.go | 70 ++++++++++++ auth/basic_auth_validator_test.go | 102 ++++++++++++++++++ auth/jwt.go | 10 +- .../jwt_validator_test.go | 42 +++++--- config/config.go | 15 ++- frontman.yaml | 19 ++-- handlers.go | 16 +-- service/service.go | 2 + 9 files changed, 240 insertions(+), 43 deletions(-) create mode 100644 auth/basic.go create mode 100644 auth/basic_auth_validator_test.go rename jwt_validator_test.go => auth/jwt_validator_test.go (79%) diff --git a/auth/auth.go b/auth/auth.go index fea8cd8..822598a 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -3,16 +3,19 @@ package auth import ( "errors" "github.com/Frontman-Labs/frontman/config" + "net/http" ) type TokenValidator interface { - ValidateToken(tokenString string) (map[string]interface{}, error) + ValidateToken(request *http.Request) (map[string]interface{}, error) } func GetTokenValidator(conf config.AuthConfig) (TokenValidator, error) { switch conf.AuthType { case "jwt": - return NewJWTValidator(conf.JWT.Audience, conf.JWT.Issuer, conf.JWT.KeysUrl), nil + return NewJWTValidator(conf.JWT.Audience, conf.JWT.Issuer, conf.JWT.KeysUrl) + case "basic": + return NewBasicAuthValidator(conf.BasicAuthConfig) default: return nil, errors.New("Unrecognized auth type specified") } diff --git a/auth/basic.go b/auth/basic.go new file mode 100644 index 0000000..dc9bb90 --- /dev/null +++ b/auth/basic.go @@ -0,0 +1,70 @@ +package auth + +import ( + "errors" + "io/ioutil" + "log" + "net/http" + "os" + + "github.com/Frontman-Labs/frontman/config" + "gopkg.in/yaml.v3" +) + +type BasicAuthValidator struct { + Username string `yaml:"username"` + Password string `yaml:"password"` +} + +func getCredentialsFromConfig(conf *config.BasicAuthConfig) (string, string) { + var username, password string + if conf.Username != "" { + username = conf.Username + } else { + username = os.Getenv(conf.UsernameEnv) + } + + if conf.Password != "" { + password = conf.Password + } else { + password = os.Getenv(conf.PasswordEnv) + } + + return username, password +} + +func NewBasicAuthValidator(conf *config.BasicAuthConfig) (*BasicAuthValidator, error) { + if conf.CredentialsFile != "" { + // Read credentials file to build validator + yamlData, err := ioutil.ReadFile(conf.CredentialsFile) + if err != nil { + log.Printf("Failed to read credentials file: %s", err) + return nil, err + } + validator := &BasicAuthValidator{} + err = yaml.Unmarshal(yamlData, validator) + if err != nil { + log.Printf("Failed to unmarshal credentials data: %s", err) + return nil, err + } + return validator, nil + } + username, password := getCredentialsFromConfig(conf) + return &BasicAuthValidator{ + Username: username, + Password: password, + }, nil +} + +func (v BasicAuthValidator) ValidateToken(request *http.Request) (map[string]interface{}, error) { + username, password, ok := request.BasicAuth() + if !ok { + return nil, errors.New("Error parsing authentication token") + } + + if username != v.Username || password != v.Password { + return nil, errors.New("Invalid credentials") + } + + return nil, nil +} diff --git a/auth/basic_auth_validator_test.go b/auth/basic_auth_validator_test.go new file mode 100644 index 0000000..42e6a77 --- /dev/null +++ b/auth/basic_auth_validator_test.go @@ -0,0 +1,102 @@ +package auth + +import ( + "github.com/Frontman-Labs/frontman/config" + "net/http" + "os" + "testing" +) + +func TestNewBasicAuthValidatorFromHardcodedCredentials(t *testing.T) { + conf := &config.BasicAuthConfig{ + Username: "username", + Password: "password", + } + + validator, err := NewBasicAuthValidator(conf) + if err != nil { + t.Errorf("Failed to create basic validator: %s\n", err) + } + if validator.Username != "username" { + t.Errorf("NewBasicAuthValidator failed to parse username from username config variable\n") + } + if validator.Password != "password" { + t.Errorf("NewBasicAuthValidator failed to parse password from password config variable\n") + } +} + +func TestNewBasicAuthValidatorFromEnvVariables(t *testing.T) { + conf := &config.BasicAuthConfig{ + UsernameEnv: "FRONTMAN_TEST_BACKEND_USERNAME", + PasswordEnv: "FRONTMAN_TEST_BACKEND_PASSWORD", + } + + os.Setenv("FRONTMAN_TEST_BACKEND_USERNAME", "username_from_env") + os.Setenv("FRONTMAN_TEST_BACKEND_PASSWORD", "password_from_env") + + validator, err := NewBasicAuthValidator(conf) + if err != nil { + t.Errorf("Failed to create basic validator: %s\n", err) + } + if validator.Username != "username_from_env" { + t.Errorf("NewBasicAuthValidator failed to parse username from username environment variable\n") + } + if validator.Password != "password_from_env" { + t.Errorf("NewBasicAuthValidator failed to parse password from password environment variable\n") + } +} + +func TestBasicAuthValidCredentials(t *testing.T) { + validator := &BasicAuthValidator{ + Username: "test", + Password: "test", + } + + req := &http.Request{ + Header: make(http.Header), + } + req.SetBasicAuth("test", "test") + _, err := validator.ValidateToken(req) + if err != nil { + t.Errorf("Failed to validate correct basic auth: %s\n", err) + } +} + +func TestBasicAuthInvalidCredentials(t *testing.T) { + validator := &BasicAuthValidator{ + Username: "test", + Password: "test", + } + + req := &http.Request{ + Header: make(http.Header), + } + req.SetBasicAuth("blah", "blah") + _, err := validator.ValidateToken(req) + if err == nil { + t.Errorf("Failed to validate correctly identify invalid basic auth credentials\n") + } + + if err.Error() != "Invalid credentials" { + t.Errorf("Invalid error message returned when parsing invalid credentials: %s\n", err) + } +} + +func TestBasicAuthMissingCredentials(t *testing.T) { + validator := &BasicAuthValidator{ + Username: "test", + Password: "test", + } + + req := &http.Request{ + Header: make(http.Header), + } + _, err := validator.ValidateToken(req) + if err == nil { + t.Errorf("Failed to validate correctly identify missing basic auth credentials\n") + } + + if err.Error() != "Error parsing authentication token" { + t.Errorf("Invalid error message returned when parsing invalid credentials: %s\n", err) + } +} diff --git a/auth/jwt.go b/auth/jwt.go index b78e150..f259775 100644 --- a/auth/jwt.go +++ b/auth/jwt.go @@ -8,6 +8,7 @@ import ( "github.com/lestrrat-go/jwx/v2/jwk" "github.com/lestrrat-go/jwx/v2/jws" "github.com/lestrrat-go/jwx/v2/jwt" + "net/http" ) type JWTValidator struct { @@ -16,20 +17,21 @@ type JWTValidator struct { JWKS jwk.Set } -func NewJWTValidator(issuer string, audience string, jwkUrl string) *JWTValidator { +func NewJWTValidator(issuer string, audience string, jwkUrl string) (*JWTValidator, error) { jwks, err := jwk.Fetch(context.Background(), jwkUrl) if err != nil { log.Printf("Error loading jwks from %s: %s", jwkUrl, err.Error()) - return nil + return nil, err } return &JWTValidator{ issuer: issuer, audience: audience, JWKS: jwks, - } + }, nil } -func (v JWTValidator) ValidateToken(tokenString string) (map[string]interface{}, error) { +func (v JWTValidator) ValidateToken(request *http.Request) (map[string]interface{}, error) { + tokenString := request.Header.Get("Authorization") splitToken := strings.Fields(tokenString) // Remove leading "Bearer " token := splitToken[len(splitToken)-1] diff --git a/jwt_validator_test.go b/auth/jwt_validator_test.go similarity index 79% rename from jwt_validator_test.go rename to auth/jwt_validator_test.go index d68bcb7..831a4ca 100644 --- a/jwt_validator_test.go +++ b/auth/jwt_validator_test.go @@ -1,19 +1,19 @@ -package frontman +package auth import ( "crypto/rand" "crypto/rsa" - "testing" - "time" - - "github.com/Frontman-Labs/frontman/auth" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jwk" "github.com/lestrrat-go/jwx/v2/jwt" + "net/http" + "testing" + "time" ) -func TestValidateToken(t *testing.T) { - validator := auth.JWTValidator{ +// TestGetServicesHandler tests the getServicesHandler function +func TestValidateJWTToken(t *testing.T) { + validator := JWTValidator{ JWKS: jwk.NewSet(), } privKey, err := rsa.GenerateKey(rand.Reader, 2048) @@ -41,7 +41,11 @@ func TestValidateToken(t *testing.T) { if err != nil { t.Errorf("failed to generate signed serialized: %s\n", err) } - result, err := validator.ValidateToken(string(signed)) + headers := make(http.Header) + headers.Add("Authorization", string(signed)) + result, err := validator.ValidateToken(&http.Request{ + Header: headers, + }) if err != nil { t.Errorf("Failed to validate signed token: %s", err) } @@ -50,8 +54,8 @@ func TestValidateToken(t *testing.T) { } } -func TestValidateTokenInvalidSignature(t *testing.T) { - validator := auth.JWTValidator{ +func TestValidateJWTTokenInvalidSignature(t *testing.T) { + validator := JWTValidator{ JWKS: jwk.NewSet(), } privKey, err := rsa.GenerateKey(rand.Reader, 2048) @@ -83,14 +87,18 @@ func TestValidateTokenInvalidSignature(t *testing.T) { if err != nil { t.Errorf("failed to generate signed serialized: %s\n", err) } - _, err = validator.ValidateToken(string(signed)) + headers := make(http.Header) + headers.Add("Authorization", string(signed)) + _, err = validator.ValidateToken(&http.Request{ + Header: headers, + }) if err == nil { t.Errorf("Failed to detect invalid key") } } -func TestValidateExpiredToken(t *testing.T) { - validator := auth.JWTValidator{ +func TestValidateJWTExpiredToken(t *testing.T) { + validator := JWTValidator{ JWKS: jwk.NewSet(), } privKey, err := rsa.GenerateKey(rand.Reader, 2048) @@ -124,9 +132,13 @@ func TestValidateExpiredToken(t *testing.T) { if err != nil { t.Errorf("failed to generate signed serialized: %s\n", err) } - _, err = validator.ValidateToken(string(signed)) + headers := make(http.Header) + headers.Add("Authorization", string(signed)) + _, err = validator.ValidateToken(&http.Request{ + Header: headers, + }) if err == nil { - t.Errorf("Failed to detect invalid key") + t.Errorf("Failed to detect invalid key: %s", err) } if err.Error() != "\"exp\" not satisfied" { diff --git a/config/config.go b/config/config.go index 13d0c89..b1cd4b6 100644 --- a/config/config.go +++ b/config/config.go @@ -32,11 +32,20 @@ type JWTConfig struct { KeysUrl string `json:"keysUrl" yaml:"keysUrl"` } +type BasicAuthConfig struct { + Username string `json:"username" yaml:"username"` + Password string `json:"password" yaml:"password"` + UsernameEnv string `json:"usernameEnvVariable" yaml:"usernameEnvVariable"` + PasswordEnv string `json:"passwordEnvVariable" yaml:"passwordEnvVariable"` + CredentialsFile string `json:"credentialsFile" yaml:"credentialsFile"` +} + // Auth config type AuthConfig struct { - AuthType string `json:"type" yaml:"type"` - UserDataHeader string `json:"userDataHeader" yaml:"userDataHeader"` - JWT *JWTConfig `json:"jwt" yaml:"jwt"` + AuthType string `json:"type" yaml:"type"` + UserDataHeader string `json:"userDataHeader" yaml:"userDataHeader"` + JWT *JWTConfig `json:"jwt" yaml:"jwt"` + BasicAuthConfig *BasicAuthConfig `json:"basic" yaml:"basic"` } // APIConfig holds the API server configuration diff --git a/frontman.yaml b/frontman.yaml index 781de78..6b328e9 100644 --- a/frontman.yaml +++ b/frontman.yaml @@ -1,29 +1,24 @@ global: - service_type: SERVICE_TYPE - services_file: SERVICES_FILE - redis_uri: REDIS_URI - redis_namespace: REDIS_NAMESPACE - mongo_uri: MONGO_URI - mongo_db_name: MONGO_DB_NAME - mongo_collection_name: MONGO_COLLECTION_NAME + service_type: yaml + services_file: services.yaml api: - addr: API_ADDR + addr: :8080 ssl: - enabled: API_SSL_ENABLED + enabled: false cert: API_SSL_CERT key: API_SSL_KEY gateway: - addr: GATEWAY_ADDR + addr: :8000 ssl: - enabled: GATEWAY_SSL_ENABLED + enabled: false cert: GATEWAY_SSL_CERT key: GATEWAY_SSL_KEY logging: - level: LOG_LEVEL + level: DEBUG diff --git a/handlers.go b/handlers.go index b4288bf..c5e5965 100644 --- a/handlers.go +++ b/handlers.go @@ -265,7 +265,6 @@ func gatewayHandler(bs service.ServiceRegistry, plugs []plugins.FrontmanPlugin, // Get the upstream target URL for this request upstreamTarget := backendService.GetLoadBalancer().ChooseTarget(backendService.UpstreamTargets) - var urlPath string if backendService.StripPath { urlPath = strings.TrimPrefix(r.URL.Path, backendService.Path) @@ -289,17 +288,20 @@ func gatewayHandler(bs service.ServiceRegistry, plugs []plugins.FrontmanPlugin, if backendService.AuthConfig != nil { tokenValidator := backendService.GetTokenValidator() // Backend service has auth config specified - claims, err := tokenValidator.ValidateToken(headers.Get("Authorization")) + claims, err := tokenValidator.ValidateToken(r) if err != nil { http.Error(w, err.Error(), http.StatusUnauthorized) return } - data, err := json.Marshal(claims) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return + + if claims != nil { + data, err := json.Marshal(claims) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + headers.Add(backendService.GetUserDataHeader(), string(data)) } - headers.Add(backendService.GetUserDataHeader(), string(data)) } // Remove the X-Forwarded-For header to prevent spoofing diff --git a/service/service.go b/service/service.go index 804f439..0807d60 100644 --- a/service/service.go +++ b/service/service.go @@ -138,6 +138,8 @@ func (bs *BackendService) setLoadBalancer() { bs.loadBalancer = loadbalancer.NewRoundRobinLoadBalancer() case loadbalancer.WeightedRoundRobin: bs.loadBalancer = loadbalancer.NewWRoundRobinLoadBalancer(bs.LoadBalancerPolicy.Options.Weights) + default: + bs.loadBalancer = loadbalancer.NewRoundRobinLoadBalancer() } } From 9b4f7e6f1d6528aed8615ba2c99fc8c84fc24545 Mon Sep 17 00:00:00 2001 From: nhaydel Date: Sat, 18 Mar 2023 13:42:43 -0500 Subject: [PATCH 2/4] undo frontman.yaml changes --- frontman.yaml | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/frontman.yaml b/frontman.yaml index 6b328e9..781de78 100644 --- a/frontman.yaml +++ b/frontman.yaml @@ -1,24 +1,29 @@ global: - service_type: yaml - services_file: services.yaml + service_type: SERVICE_TYPE + services_file: SERVICES_FILE + redis_uri: REDIS_URI + redis_namespace: REDIS_NAMESPACE + mongo_uri: MONGO_URI + mongo_db_name: MONGO_DB_NAME + mongo_collection_name: MONGO_COLLECTION_NAME api: - addr: :8080 + addr: API_ADDR ssl: - enabled: false + enabled: API_SSL_ENABLED cert: API_SSL_CERT key: API_SSL_KEY gateway: - addr: :8000 + addr: GATEWAY_ADDR ssl: - enabled: false + enabled: GATEWAY_SSL_ENABLED cert: GATEWAY_SSL_CERT key: GATEWAY_SSL_KEY logging: - level: DEBUG + level: LOG_LEVEL From effa961d1f288d3559638f4b6c999d6d3a8e070d Mon Sep 17 00:00:00 2001 From: nhaydel Date: Mon, 20 Mar 2023 07:31:36 -0500 Subject: [PATCH 3/4] Add auth instructions to readme --- README.md | 52 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 98d635e..154b8bf 100644 --- a/README.md +++ b/README.md @@ -249,6 +249,56 @@ You can add, update, and remove backend services using the following REST endpoi - PUT /services/{name} - Updates an existing backend service - DELETE /services/{name} - Removes a backend service +## Adding authentication to backend services +Frontman currently supports two methods of authentication: JWT tokens and Basic Auth. Authentication can be configured for each backend service separately using the `auth` configuration +option: + +- Basic Auth with Username and Password In config: +```yaml + # .. backend config + auth: + type: "basic" + basic: + username: "test" + password: "test" +``` + +- Basic Auth with username and password environment variable: +```yaml + # .. backend config + auth: + type: "basic" + basic: + usernameEnvVariable: "API_USERNAME" + passwordEnvVariable: "API_PASSWORD" +``` + +- Basic Auth with username and password stored in credentials file: +```yaml + # .. backend config + auth: + type: "basic" + basic: + credentialsFile: "credentials.yaml" +``` + +credentials.yaml: +```yaml +username: "filetest" +password: "filetest" +``` + +- JWT Auth: +```yaml + # .. backend config + type: "jwt" + userDataContextKey: "user" # Header for storing user claims + jwt: + audience: + issuer: + keysUrl: +``` + ## Frontman Plugins Frontman allows you to create custom plugins that can be used to extend its functionality. Plugins are implemented using the FrontmanPlugin interface, which consists of three methods: @@ -310,4 +360,4 @@ Once you have updated the configuration file, restart Frontman to load the new p If you'd like to contribute to Frontman, please fork the repository and submit a pull request. We welcome bug reports, feature requests, and code contributions. ## License -Frontman is released under the GNU General Public License. See LICENSE for details. \ No newline at end of file +Frontman is released under the GNU General Public License. See LICENSE for details. From 824c75cb96f3dbe088ef62e5fa0db62264d5b558 Mon Sep 17 00:00:00 2001 From: nhaydel Date: Mon, 20 Mar 2023 07:33:31 -0500 Subject: [PATCH 4/4] Fix jwt auth example --- README.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 154b8bf..ef36fcd 100644 --- a/README.md +++ b/README.md @@ -291,12 +291,13 @@ password: "filetest" - JWT Auth: ```yaml # .. backend config - type: "jwt" - userDataContextKey: "user" # Header for storing user claims - jwt: - audience: - issuer: - keysUrl: + auth: + type: "jwt" + userDataContextKey: "user" # Header for storing user claims + jwt: + audience: + issuer: + keysUrl: ``` ## Frontman Plugins