From 02f4bb1b34bc1a1fb92d9e12bee2d47f6738cb11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20B=C5=82aszczyk?= Date: Tue, 27 Aug 2019 11:52:23 +0200 Subject: [PATCH] Introduce JWT validation (#18) * Add OWNERS * Add OWNERS * Create base jwt validation --- README.md | 2 + api/v2alpha1/jwt_types.go | 33 ++++++++ api/v2alpha1/zz_generated.deepcopy.go | 108 ++++++++++++++++++++++++++ config/samples/invalid.yaml | 40 ++++++++++ config/samples/valid.yaml | 20 +++++ go.mod | 1 + go.sum | 2 + internal/validation/jwt.go | 49 +++++++++++- internal/validation/jwt_test.go | 52 +++++++++++++ internal/validation/oauth.go | 4 +- 10 files changed, 308 insertions(+), 3 deletions(-) create mode 100644 api/v2alpha1/jwt_types.go create mode 100644 internal/validation/jwt_test.go diff --git a/README.md b/README.md index df2a9209b..6fbe08b9b 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ auth: mode: name: INCLUDE config: + paths: - path: '/a' scopes: - read @@ -98,6 +99,7 @@ service: auth: name: OAUTH config: + paths: - path: '/a' scopes: - write diff --git a/api/v2alpha1/jwt_types.go b/api/v2alpha1/jwt_types.go new file mode 100644 index 000000000..826e46321 --- /dev/null +++ b/api/v2alpha1/jwt_types.go @@ -0,0 +1,33 @@ +package v2alpha1 + +import "k8s.io/apimachinery/pkg/runtime" + +// JWTModeConfig config for JWT mode +type JWTModeConfig struct { + Issuer string `json:"issuer"` + JWKS []string `json:"jwks,omitempty"` + Mode InternalConfig `json:"mode"` +} + +// InternalConfig internal config, specific for JWT modes +type InternalConfig struct { + Name string `json:"name"` + Config *runtime.RawExtension `json:"config,omitempty"` +} + +// JWTModeALL representation of config for the ALL mode +type JWTModeALL struct { + Scopes []string `json:"scopes"` +} + +// JWTModeInclude representation of config for the INCLUDE mode +type JWTModeInclude struct { + Paths []IncludePath `json:"paths"` +} + +// IncludePath Path for INCLUDE mode +type IncludePath struct { + Path string `json:"path"` + Scopes []string `json:"scopes"` + Methods []string `json:"methods"` +} diff --git a/api/v2alpha1/zz_generated.deepcopy.go b/api/v2alpha1/zz_generated.deepcopy.go index e7d1c9d1b..79583ac11 100644 --- a/api/v2alpha1/zz_generated.deepcopy.go +++ b/api/v2alpha1/zz_generated.deepcopy.go @@ -193,6 +193,114 @@ func (in *GatewayResourceStatus) DeepCopy() *GatewayResourceStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IncludePath) DeepCopyInto(out *IncludePath) { + *out = *in + if in.Scopes != nil { + in, out := &in.Scopes, &out.Scopes + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Methods != nil { + in, out := &in.Methods, &out.Methods + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IncludePath. +func (in *IncludePath) DeepCopy() *IncludePath { + if in == nil { + return nil + } + out := new(IncludePath) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InternalConfig) DeepCopyInto(out *InternalConfig) { + *out = *in + if in.Config != nil { + in, out := &in.Config, &out.Config + *out = new(runtime.RawExtension) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InternalConfig. +func (in *InternalConfig) DeepCopy() *InternalConfig { + if in == nil { + return nil + } + out := new(InternalConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *JWTModeALL) DeepCopyInto(out *JWTModeALL) { + *out = *in + if in.Scopes != nil { + in, out := &in.Scopes, &out.Scopes + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new JWTModeALL. +func (in *JWTModeALL) DeepCopy() *JWTModeALL { + if in == nil { + return nil + } + out := new(JWTModeALL) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *JWTModeConfig) DeepCopyInto(out *JWTModeConfig) { + *out = *in + if in.JWKS != nil { + in, out := &in.JWKS, &out.JWKS + *out = make([]string, len(*in)) + copy(*out, *in) + } + in.Mode.DeepCopyInto(&out.Mode) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new JWTModeConfig. +func (in *JWTModeConfig) DeepCopy() *JWTModeConfig { + if in == nil { + return nil + } + out := new(JWTModeConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *JWTModeInclude) DeepCopyInto(out *JWTModeInclude) { + *out = *in + if in.Paths != nil { + in, out := &in.Paths, &out.Paths + *out = make([]IncludePath, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new JWTModeInclude. +func (in *JWTModeInclude) DeepCopy() *JWTModeInclude { + if in == nil { + return nil + } + out := new(JWTModeInclude) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *OauthModeConfig) DeepCopyInto(out *OauthModeConfig) { *out = *in diff --git a/config/samples/invalid.yaml b/config/samples/invalid.yaml index d625c4891..3c41695cd 100644 --- a/config/samples/invalid.yaml +++ b/config/samples/invalid.yaml @@ -60,3 +60,43 @@ spec: - path: /foo scopes: [foo] methods: [POST] +--- +apiVersion: gateway.kyma-project.io/v2alpha1 +kind: Gate +metadata: + name: jwt-bad-issuer +spec: + gateway: kyma-gateway.kyma-system.svc.cluster.local + service: + name: foo-service + port: 8080 + host: foo.bar + auth: + name: JWT + config: + issuer: not-a-valid-url + jwks: [] + mode: + name: ALL + config: + scopes: ["foo", "bar"] +--- +apiVersion: gateway.kyma-project.io/v2alpha1 +kind: Gate +metadata: + name: jwt-bad-mode +spec: + gateway: kyma-gateway.kyma-system.svc.cluster.local + service: + name: foo-service + port: 8080 + host: foo.bar + auth: + name: JWT + config: + issuer: http://dex.kyma.local + jwks: [] + mode: + name: FOO + config: + foo: bar diff --git a/config/samples/valid.yaml b/config/samples/valid.yaml index 50e4fcb7d..881b29540 100644 --- a/config/samples/valid.yaml +++ b/config/samples/valid.yaml @@ -29,3 +29,23 @@ spec: # - path: /foo # scopes: [foo, bar] # methods: [GET] +# --- +# apiVersion: gateway.kyma-project.io/v2alpha1 +# kind: Gate +# metadata: +# name: jwt-all +# spec: +# gateway: kyma-gateway.kyma-system.svc.cluster.local +# service: +# name: foo-service +# port: 8080 +# host: foo.bar +# auth: +# name: JWT +# config: +# issuer: http://dex.kyma.local +# jwks: [] +# mode: +# name: ALL +# config: +# scopes: ["foo", "bar"] diff --git a/go.mod b/go.mod index 37d928adc..1c4069ff5 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/kyma-incubator/api-gateway go 1.12 require ( + github.com/ghodss/yaml v1.0.0 github.com/go-logr/logr v0.1.0 github.com/google/go-cmp v0.3.1 // indirect github.com/onsi/ginkgo v1.6.0 diff --git a/go.sum b/go.sum index 7b9fa4dca..0880f9081 100644 --- a/go.sum +++ b/go.sum @@ -9,6 +9,8 @@ github.com/evanphx/json-patch v4.5.0+incompatible h1:ouOWdg56aJriqS0huScTkVXPC5I github.com/evanphx/json-patch v4.5.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-logr/logr v0.1.0 h1:M1Tv3VzNlEHg6uyACnRdtrploV2P7wZqH8BoQMtz0cg= github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas= github.com/go-logr/zapr v0.1.0 h1:h+WVe9j6HAA01niTJPA/kKH0i7e0rLZBCwauQFcRE54= diff --git a/internal/validation/jwt.go b/internal/validation/jwt.go index 998d9878f..1002d5044 100644 --- a/internal/validation/jwt.go +++ b/internal/validation/jwt.go @@ -1,9 +1,56 @@ package validation -import "k8s.io/apimachinery/pkg/runtime" +import ( + "encoding/json" + "fmt" + "net/url" + + gatewayv2alpha1 "github.com/kyma-incubator/api-gateway/api/v2alpha1" + "github.com/pkg/errors" + "k8s.io/apimachinery/pkg/runtime" +) + +var ( + jwtModes = []string{"ALL", "INCLUDE", "EXCLUDE"} +) type jwt struct{} func (j *jwt) Validate(config *runtime.RawExtension) error { + var template gatewayv2alpha1.JWTModeConfig + + if !configNotEmpty(config) { + return fmt.Errorf("supplied config cannot be empty") + } + err := json.Unmarshal(config.Raw, &template) + if err != nil { + return errors.WithStack(err) + } + if !j.isValidURL(template.Issuer) { + return fmt.Errorf("issuer field is empty or not a valid url") + } + if !j.isValidMode(template.Mode.Name) { + return fmt.Errorf("supplied mode is invalid: %v, valid modes are: ALL, INCLUDE, EXCLUDE", template.Mode.Name) + } return nil } + +func (j *jwt) isValidURL(toTest string) bool { + if len(toTest) == 0 { + return false + } + _, err := url.ParseRequestURI(toTest) + if err != nil { + return false + } + return true +} + +func (j *jwt) isValidMode(mode string) bool { + for _, b := range jwtModes { + if b == mode { + return true + } + } + return false +} diff --git a/internal/validation/jwt_test.go b/internal/validation/jwt_test.go new file mode 100644 index 000000000..39bcca8a3 --- /dev/null +++ b/internal/validation/jwt_test.go @@ -0,0 +1,52 @@ +package validation_test + +import ( + "testing" + + "github.com/ghodss/yaml" + gatewayv2alpha1 "github.com/kyma-incubator/api-gateway/api/v2alpha1" + "github.com/kyma-incubator/api-gateway/internal/validation" + "gotest.tools/assert" + "k8s.io/apimachinery/pkg/runtime" + logf "sigs.k8s.io/controller-runtime/pkg/runtime/log" +) + +var ( + validYamlForJWT = ` +issuer: http://dex.kyma.local +jwks: ["a", "b"] +mode: + name: ALL + config: + scopes: ["foo", "bar"] +` + invalidIssuer = ` +issuer: this-is-not-an-url +` + invalidJWTMode = ` +issuer: http://dex.kyma.local +jwks: ["a", "b"] +mode: + name: CLASSIFIED_MODE_DONT_USE + config: + top: secret +` + logJWT = logf.Log.WithName("jwt-validate-test") +) + +func TestJWTValidate(t *testing.T) { + strategy, err := validation.NewFactory(logJWT).StrategyFor(gatewayv2alpha1.JWT) + assert.NilError(t, err) + + jsonData, err := yaml.YAMLToJSON([]byte(invalidIssuer)) + assert.NilError(t, err) + assert.Error(t, strategy.Validate(&runtime.RawExtension{Raw: jsonData}), "issuer field is empty or not a valid url") + + jsonData, err = yaml.YAMLToJSON([]byte(invalidJWTMode)) + assert.NilError(t, err) + assert.Error(t, strategy.Validate(&runtime.RawExtension{Raw: jsonData}), "supplied mode is invalid: CLASSIFIED_MODE_DONT_USE, valid modes are: ALL, INCLUDE, EXCLUDE") + + jsonData, err = yaml.YAMLToJSON([]byte(validYamlForJWT)) + assert.NilError(t, err) + assert.NilError(t, strategy.Validate(&runtime.RawExtension{Raw: jsonData})) +} diff --git a/internal/validation/oauth.go b/internal/validation/oauth.go index a60130862..06e94cc8a 100644 --- a/internal/validation/oauth.go +++ b/internal/validation/oauth.go @@ -28,13 +28,13 @@ func (o *oauth) Validate(config *runtime.RawExtension) error { if len(template.Paths) == 0 { return fmt.Errorf("supplied config does not match internal template") } - if hasDuplicates(template.Paths) { + if o.hasDuplicates(template.Paths) { return fmt.Errorf("supplied config is invalid: multiple definitions of the same path detected") } return nil } -func hasDuplicates(elements []gatewayv2alpha1.Option) bool { +func (o *oauth) hasDuplicates(elements []gatewayv2alpha1.Option) bool { encountered := map[string]bool{} // Create a map of all unique elements. for v := range elements {