diff --git a/doc/cookbook/security.md b/doc/cookbook/security.md index 5612b6acd7..dee108b45f 100644 --- a/doc/cookbook/security.md +++ b/doc/cookbook/security.md @@ -7,11 +7,13 @@ - [JWT](#jwt) - [Signature](#signature) - [OAuth2](#oauth2) + - [Basic Auth](#basic-auth) - [References](#references) - [Header](#header-1) - [JWT](#jwt-1) - [Signature](#signature-1) - [OAuth2](#oauth2-1) + - [Basic Auth](#basic-auth-1) - [Concepts](#concepts) As a production-ready cloud-native traffic orchestrator, Easegress cares about security and provides several features to ensure that. @@ -134,13 +136,36 @@ filters: insecureTls: false - name: proxy kind: Proxy - ``` * The example above uses a token introspection server, which is provided by `endpoint` filed for validation. It also supports `Self-Encoded Access Tokens mode` which will require a JWT related configuration included. Check it out in the Easegress filter doc if needed. [5] * For the full YAML, see [here](#oauth-1) +### Basic Auth + +* Using Basic Auth validation in Easegress. Basic access authentication is the simplest technique for enforcing access control to web resources [6]. You can create .htpasswd file using *apache2-util* `htpasswd` [7] for storing encrypted user credentials. Please note that Basic Auth is not the most secure access control technique and it is not recommended to depend solely to Basic Auth when designing the security features of your environment. + +``` yaml +name: pipeline-reverse-proxy +kind: HTTPPipeline +flow: + - filter: oauth-validator + - filter: proxy +filters: + - kind: Validator + name: oauth-validator + basicAuth: + mode: "FILE" + userFile: '/etc/apache2/.htpasswd' + - name: proxy + kind: Proxy +``` + +* The example above uses credentials defined in `/etc/apache2/.htpasswd` to restrict access. Please check out apache2-utils documentation [7] for more details. + +* For the full YAML, see [here](#basic-auth-1) + ## References ### Header @@ -167,7 +192,6 @@ filters: - url: http://127.0.0.1:9097 loadBalance: policy: roundRobin - ``` ### JWT @@ -247,6 +271,30 @@ filters: policy: roundRobin ``` +### Basic Auth + +``` yaml +name: pipeline-reverse-proxy +kind: HTTPPipeline +flow: + - filter: header-validator + - filter: proxy +filters: + - kind: Validator + name: basic-auth-validator + basicAuth: + mode: "FILE" + userFile: '/etc/apache2/.htpasswd' + - name: proxy + kind: Proxy + mainPool: + servers: + - url: http://127.0.0.1:9095 + - url: http://127.0.0.1:9096 + - url: http://127.0.0.1:9097 + loadBalance: + policy: roundRobin +``` ### Concepts @@ -255,3 +303,5 @@ filters: 3. https://github.com/megaease/easegress/blob/main/doc/filters.md#signerliteral 4. https://oauth.net/2/ 5. https://github.com/megaease/easegress/blob/main/doc/filters.md#validatorOAuth2JWT +6. https://en.wikipedia.org/wiki/Basic_access_authentication +7. https://manpages.debian.org/testing/apache2-utils/htpasswd.1.en.html diff --git a/doc/reference/filters.md b/doc/reference/filters.md index 6dbb06e9f6..8975c80d87 100644 --- a/doc/reference/filters.md +++ b/doc/reference/filters.md @@ -545,7 +545,7 @@ The filter always returns an empty result. ## Validator -The Validator filter validates requests, forwards valid ones, and rejects invalid ones. Four validation methods (`headers`, `jwt`, `signature`, and `oauth2`) are supported up to now, and these methods can either be used together or alone. When two or more methods are used together, a request needs to pass all of them to be forwarded. +The Validator filter validates requests, forwards valid ones, and rejects invalid ones. Four validation methods (`headers`, `jwt`, `signature`, `oauth2` and `basicAuth`) are supported up to now, and these methods can either be used together or alone. When two or more methods are used together, a request needs to pass all of them to be forwarded. Below is an example configuration for the `headers` validation method. Requests which has a header named `Is-Valid` with value `abc` or `goodplan` or matches regular expression `^ok-.+$` are considered to be valid. @@ -592,6 +592,15 @@ oauth2: insecureTls: false ``` +Here's an example for `basicAuth` validation method which uses [Apache2 htpasswd](https://manpages.debian.org/testing/apache2-utils/htpasswd.1.en.html) formatted encrypted password file for validation. +```yaml +kind: Validator +name: basicAuth-validator-example +basicAuth: + mode: "FILE" + userFile: /etc/apache2/.htpasswd +``` + ### Configuration | Name | Type | Description | Required | @@ -600,6 +609,7 @@ oauth2: | jwt | [validator.JWTValidatorSpec](#validatorJWTValidatorSpec) | JWT validation rule, validates JWT token string from the `Authorization` header or cookies | No | | signature | [signer.Spec](#signerSpec) | Signature validation rule, implements an [Amazon Signature V4](https://docs.aws.amazon.com/general/latest/gr/sigv4_signing.html) compatible signature validation validator, with customizable literal strings | No | | oauth2 | [validator.OAuth2ValidatorSpec](#validatorOAuth2ValidatorSpec) | The `OAuth/2` method support `Token Introspection` mode and `Self-Encoded Access Tokens` mode, only one mode can be configured at a time | No | +| basicAuth | [basicauth.BasicAuthValidatorSpec](#basicauthBasicAuthValidatorSpec) | The `BasicAuth` method support `FILE` mode and `ETCD` mode, only one mode can be configured at a time. | No | ### Results diff --git a/go.mod b/go.mod index b4a4732eb8..989923a879 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 // indirect github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4 // indirect github.com/fatih/color v1.12.0 + github.com/fsnotify/fsnotify v1.4.9 github.com/ghodss/yaml v1.0.0 github.com/go-chi/chi/v5 v5.0.3 github.com/go-zookeeper/zk v1.0.2 @@ -51,6 +52,7 @@ require ( github.com/spf13/viper v1.8.1 github.com/stretchr/testify v1.7.0 github.com/tcnksm/go-httpstat v0.2.1-0.20191008022543-e866bb274419 + github.com/tg123/go-htpasswd v1.2.0 github.com/tidwall/gjson v1.11.0 github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce github.com/valyala/fasttemplate v1.2.1 diff --git a/go.sum b/go.sum index e8353e5c76..bd27f1c513 100644 --- a/go.sum +++ b/go.sum @@ -107,6 +107,8 @@ github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBp github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962 h1:KeNholpO2xKjgaaSyd+DyQRrsQjhbSeS7qe4nEw8aQw= +github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962/go.mod h1:kC29dT1vFpj7py2OvG1khBdQpo3kInWP+6QipLbdngo= github.com/GoogleCloudPlatform/k8s-cloud-provider v0.0.0-20200415212048-7901bc822317/go.mod h1:DF8FZRxMHMGv/vP2lQP6h+dYzzjpuRn24VeRiYn3qjQ= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver v1.4.2/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= @@ -1266,6 +1268,8 @@ github.com/tcnksm/go-httpstat v0.2.1-0.20191008022543-e866bb274419 h1:elOIj31UL4 github.com/tcnksm/go-httpstat v0.2.1-0.20191008022543-e866bb274419/go.mod h1:s3JVJFtQxtBEBC9dwcdTTXS9xFnM3SXAZwPG41aurT8= github.com/tebeka/strftime v0.1.3 h1:5HQXOqWKYRFfNyBMNVc9z5+QzuBtIXy03psIhtdJYto= github.com/tebeka/strftime v0.1.3/go.mod h1:7wJm3dZlpr4l/oVK0t1HYIc4rMzQ2XJlOMIUJUJH6XQ= +github.com/tg123/go-htpasswd v1.2.0 h1:UKp34m9H467/xklxUxU15wKRru7fwXoTojtxg25ITF0= +github.com/tg123/go-htpasswd v1.2.0/go.mod h1:h7IzlfpvIWnVJhNZ0nQ9HaFxHb7pn5uFJYLlEUJa2sM= github.com/tidwall/gjson v1.11.0 h1:C16pk7tQNiH6VlCrtIXL1w8GaOsi1X3W8KDkE1BuYd4= github.com/tidwall/gjson v1.11.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= @@ -1426,6 +1430,7 @@ golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnf golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190211182817-74369b46fc67/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190228161510-8dd112bcdc25/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190313024323-a1f597ede03a/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190320223903-b7391e95e576/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= diff --git a/pkg/cluster/cluster_test.go b/pkg/cluster/cluster_test.go index 7424597bec..fd5b7dfc9e 100644 --- a/pkg/cluster/cluster_test.go +++ b/pkg/cluster/cluster_test.go @@ -19,12 +19,18 @@ package cluster import ( "fmt" + "io/ioutil" + "os" "sync" "testing" "time" + "github.com/phayes/freeport" "go.etcd.io/etcd/api/v3/mvccpb" "go.etcd.io/etcd/client/v3/concurrency" + + "github.com/megaease/easegress/pkg/env" + "github.com/megaease/easegress/pkg/option" ) func mockClusters(count int) []*cluster { @@ -130,6 +136,28 @@ func closeClusters(clusters []*cluster) { } } +func createSecondaryNode(clusterName string, primaryListenPeerURLs []string) *cluster { + ports, err := freeport.GetFreePorts(1) + check(err) + name := fmt.Sprintf("secondary-member-x") + opt := option.New() + opt.Name = name + opt.ClusterName = clusterName + opt.ClusterRole = "secondary" + opt.ClusterRequestTimeout = "10s" + opt.Cluster.PrimaryListenPeerURLs = primaryListenPeerURLs + opt.APIAddr = fmt.Sprintf("localhost:%d", ports[0]) + + _, err = opt.Parse() + check(err) + + env.InitServerDir(opt) + + clusterInstance, err := New(opt) + check(err) + return clusterInstance.(*cluster) +} + func TestCluster(t *testing.T) { t.Run("start cluster dynamically", func(t *testing.T) { clusters := mockClusters(3) @@ -139,7 +167,11 @@ func TestCluster(t *testing.T) { }) t.Run("start static sized cluster", func(t *testing.T) { clusterNodes := mockStaticCluster(3) + primaryName := clusterNodes[0].opt.ClusterName + primaryAddress := clusterNodes[0].opt.Cluster.InitialAdvertisePeerURLs + secondaryNode := createSecondaryNode(primaryName, primaryAddress) defer closeClusters(clusterNodes) + defer closeClusters([]*cluster{secondaryNode}) }) } @@ -501,3 +533,18 @@ func TestUtilEqual(t *testing.T) { t.Error("isKeyValueEqual invalid, should equal") } } + +func TestIsLeader(t *testing.T) { + etcdDirName, err := ioutil.TempDir("", "cluster-test") + check(err) + defer os.RemoveAll(etcdDirName) + + clusterInstance := CreateClusterForTest(etcdDirName) + if !clusterInstance.IsLeader() { + t.Error("single node cluster should be leader") + } + wg := &sync.WaitGroup{} + wg.Add(1) + clusterInstance.CloseServer(wg) + wg.Wait() +} diff --git a/pkg/cluster/test_util.go b/pkg/cluster/test_util.go new file mode 100644 index 0000000000..cae77c79c0 --- /dev/null +++ b/pkg/cluster/test_util.go @@ -0,0 +1,64 @@ +/* +* Copyright (c) 2017, MegaEase +* All rights reserved. +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. + */ + +package cluster + +import ( + "fmt" + + "github.com/phayes/freeport" + + "github.com/megaease/easegress/pkg/env" + "github.com/megaease/easegress/pkg/option" +) + +func check(e error) { + if e != nil { + panic(e) + } +} + +// CreateClusterForTest creates a cluster for testing purposes. +func CreateClusterForTest(tempDir string) Cluster { + ports, err := freeport.GetFreePorts(3) + check(err) + name := fmt.Sprintf("test-member-x") + opt := option.New() + opt.Name = name + opt.ClusterName = "test-cluster" + opt.ClusterRole = "primary" + opt.ClusterRequestTimeout = "10s" + opt.Cluster.ListenClientURLs = []string{fmt.Sprintf("http://localhost:%d", ports[0])} + opt.Cluster.AdvertiseClientURLs = opt.Cluster.ListenClientURLs + opt.Cluster.ListenPeerURLs = []string{fmt.Sprintf("http://localhost:%d", ports[1])} + opt.Cluster.InitialAdvertisePeerURLs = opt.Cluster.ListenPeerURLs + opt.Cluster.InitialCluster = make(map[string]string) + opt.Cluster.InitialCluster[name] = opt.Cluster.InitialAdvertisePeerURLs[0] + opt.APIAddr = fmt.Sprintf("localhost:%d", ports[2]) + opt.DataDir = fmt.Sprintf("%s/data", tempDir) + opt.LogDir = fmt.Sprintf("%s/log", tempDir) + opt.MemberDir = fmt.Sprintf("%s/member", tempDir) + + _, err = opt.Parse() + check(err) + + env.InitServerDir(opt) + + clusterInstance, err := New(opt) + check(err) + return clusterInstance +} diff --git a/pkg/filter/headerlookup/headerlookup.go b/pkg/filter/headerlookup/headerlookup.go new file mode 100644 index 0000000000..91706e1b6a --- /dev/null +++ b/pkg/filter/headerlookup/headerlookup.go @@ -0,0 +1,264 @@ +/* +* Copyright (c) 2017, MegaEase +* All rights reserved. +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. + */ + +package headerlookup + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" + + lru "github.com/hashicorp/golang-lru" + yaml "gopkg.in/yaml.v2" + + "github.com/megaease/easegress/pkg/cluster" + httpcontext "github.com/megaease/easegress/pkg/context" + "github.com/megaease/easegress/pkg/logger" + "github.com/megaease/easegress/pkg/object/httppipeline" +) + +const ( + // Kind is the kind of HeaderLookup. + Kind = "HeaderLookup" + // customDataPrefix is prefix for lookup data. + customDataPrefix = "/custom-data/" + // size of the LRU cache + cacheSize = 128 +) + +var results = []string{} + +func init() { + httppipeline.Register(&HeaderLookup{}) +} + +type ( + // HeaderLookup retrieves values from etcd to headers. + HeaderLookup struct { + filterSpec *httppipeline.FilterSpec + spec *Spec + etcdPrefix string + headerKey string + + cache *lru.Cache + cluster cluster.Cluster + stopCtx context.Context + cancel context.CancelFunc + } + + // HeaderSetterSpec defines etcd source key and request destination header. + HeaderSetterSpec struct { + EtcdKey string `yaml:"etcdKey,omitempty" jsonschema:"omitempty"` + HeaderKey string `yaml:"headerKey,omitempty" jsonschema:"omitempty"` + } + + // Spec defines header key and etcd prefix that form etcd key like /custom-data/{etcdPrefix}/{headerKey's value}. + // This /custom-data/{etcdPrefix}/{headerKey's value} is retrieved from etcd and HeaderSetters extract keys from the + // from the retrieved etcd item. + Spec struct { + HeaderKey string `yaml:"headerKey" jsonschema:"required"` + EtcdPrefix string `yaml:"etcdPrefix" jsonschema:"required"` + HeaderSetters []*HeaderSetterSpec `yaml:"headerSetters" jsonschema:"required"` + } +) + +// Validate validates spec. +func (spec Spec) Validate() error { + if spec.HeaderKey == "" { + return fmt.Errorf("headerKey is required") + } + if spec.EtcdPrefix == "" { + return fmt.Errorf("etcdPrefix is required") + } + if len(spec.HeaderSetters) < 1 { + return fmt.Errorf("at least one headerSetter is required") + } + for _, hs := range spec.HeaderSetters { + if hs.EtcdKey == "" { + return fmt.Errorf("headerSetters[i].etcdKey is required") + } + if hs.HeaderKey == "" { + return fmt.Errorf("headerSetters[i].headerKey is required") + } + } + return nil +} + +// Kind returns the kind of HeaderLookup. +func (hl *HeaderLookup) Kind() string { + return Kind +} + +// DefaultSpec returns the default spec of HeaderLookup. +func (hl *HeaderLookup) DefaultSpec() interface{} { + return &Spec{} +} + +// Description returns the description of HeaderLookup. +func (hl *HeaderLookup) Description() string { + return "HeaderLookup enriches request headers per request, looking up values from etcd." +} + +// Results returns the results of HeaderLookup. +func (hl *HeaderLookup) Results() []string { + return results +} + +// Init initializes HeaderLookup. +func (hl *HeaderLookup) Init(filterSpec *httppipeline.FilterSpec) { + hl.filterSpec, hl.spec = filterSpec, filterSpec.FilterSpec().(*Spec) + if filterSpec.Super() != nil && filterSpec.Super().Cluster() != nil { + hl.cluster = filterSpec.Super().Cluster() + } + hl.etcdPrefix = customDataPrefix + strings.TrimPrefix(hl.spec.EtcdPrefix, "/") + hl.headerKey = http.CanonicalHeaderKey(hl.spec.HeaderKey) + hl.cache, _ = lru.New(cacheSize) + hl.stopCtx, hl.cancel = context.WithCancel(context.Background()) + hl.watchChanges() +} + +// Inherit inherits previous generation of HeaderLookup. +func (hl *HeaderLookup) Inherit(filterSpec *httppipeline.FilterSpec, previousGeneration httppipeline.Filter) { + previousGeneration.Close() + hl.Init(filterSpec) +} + +func (hl *HeaderLookup) lookup(headerVal string) (map[string]string, error) { + if val, ok := hl.cache.Get(hl.etcdPrefix + headerVal); ok { + return val.(map[string]string), nil + } + + etcdVal, err := hl.cluster.Get(hl.etcdPrefix + headerVal) + if err != nil { + return nil, err + } + if etcdVal == nil { + return nil, fmt.Errorf("no data found") + } + result := make(map[string]string, len(hl.spec.HeaderSetters)) + etcdValues := make(map[string]string) + err = yaml.Unmarshal([]byte(*etcdVal), etcdValues) + if err != nil { + return nil, err + } + for _, setter := range hl.spec.HeaderSetters { + if val, ok := etcdValues[setter.EtcdKey]; ok { + result[setter.HeaderKey] = val + } + } + + hl.cache.Add(hl.etcdPrefix+headerVal, result) + return result, nil +} + +func findKeysToDelete(kvs map[string]string, cache *lru.Cache) []string { + keysToDelete := []string{} + intersection := make(map[string]string) + for key, newValues := range kvs { + if oldValues, ok := cache.Peek(key); ok { + intersection[key] = "" + if newValues != oldValues { + keysToDelete = append(keysToDelete, key) + } + } + } + // delete cache items that were not in kvs + for _, cacheKey := range cache.Keys() { + if _, exists := intersection[cacheKey.(string)]; !exists { + keysToDelete = append(keysToDelete, cacheKey.(string)) + } + } + return keysToDelete +} + +func (hl *HeaderLookup) watchChanges() { + var ( + syncer *cluster.Syncer + err error + ch <-chan map[string]string + ) + + for { + syncer, err = hl.cluster.Syncer(30 * time.Minute) + if err != nil { + logger.Errorf("failed to create syncer: %v", err) + } else if ch, err = syncer.SyncPrefix(hl.etcdPrefix); err != nil { + logger.Errorf("failed to sync prefix: %v", err) + syncer.Close() + } else { + break + } + + select { + case <-time.After(10 * time.Second): + case <-hl.stopCtx.Done(): + return + } + } + // start listening in background + go func() { + defer syncer.Close() + + for { + select { + case <-hl.stopCtx.Done(): + return + case kvs := <-ch: + logger.Infof("HeaderLookup update") + keysToDelete := findKeysToDelete(kvs, hl.cache) + for _, cacheKey := range keysToDelete { + hl.cache.Remove(cacheKey) + } + } + } + }() + return +} + +// Close closes HeaderLookup. +func (hl *HeaderLookup) Close() { + hl.cancel() +} + +// Handle retrieves header values and sets request headers. +func (hl *HeaderLookup) Handle(ctx httpcontext.HTTPContext) string { + result := hl.handle(ctx) + return ctx.CallNextHandler(result) +} + +func (hl *HeaderLookup) handle(ctx httpcontext.HTTPContext) string { + header := ctx.Request().Header() + headerVal := header.Get(hl.headerKey) + if headerVal == "" { + logger.Warnf("request does not have header '%s'", hl.spec.HeaderKey) + return "" + } + headersToAdd, err := hl.lookup(headerVal) + if err != nil { + logger.Errorf(err.Error()) + return "" + } + for hk, hv := range headersToAdd { + header.Set(http.CanonicalHeaderKey(hk), hv) + } + return "" +} + +// Status returns status. +func (hl *HeaderLookup) Status() interface{} { return nil } diff --git a/pkg/filter/headerlookup/headerlookup_test.go b/pkg/filter/headerlookup/headerlookup_test.go new file mode 100644 index 0000000000..2c3fce5f4d --- /dev/null +++ b/pkg/filter/headerlookup/headerlookup_test.go @@ -0,0 +1,244 @@ +/* +* Copyright (c) 2017, MegaEase +* All rights reserved. +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. + */ + +package headerlookup + +import ( + "io/ioutil" + "net/http" + "os" + "sync" + "testing" + "time" + + lru "github.com/hashicorp/golang-lru" + + cluster "github.com/megaease/easegress/pkg/cluster" + "github.com/megaease/easegress/pkg/context/contexttest" + "github.com/megaease/easegress/pkg/logger" + "github.com/megaease/easegress/pkg/object/httppipeline" + "github.com/megaease/easegress/pkg/supervisor" + "github.com/megaease/easegress/pkg/util/httpheader" + "github.com/megaease/easegress/pkg/util/yamltool" +) + +func TestMain(m *testing.M) { + logger.InitNop() + code := m.Run() + os.Exit(code) +} + +func createHeaderLookup( + yamlSpec string, prev *HeaderLookup, supervisor *supervisor.Supervisor) (*HeaderLookup, error) { + rawSpec := make(map[string]interface{}) + yamltool.Unmarshal([]byte(yamlSpec), &rawSpec) + spec, err := httppipeline.NewFilterSpec(rawSpec, supervisor) + if err != nil { + return nil, err + } + hl := &HeaderLookup{} + if prev == nil { + hl.Init(spec) + } else { + hl.Inherit(spec, prev) + } + return hl, nil +} + +func check(e error) { + if e != nil { + panic(e) + } +} + +func TestValidate(t *testing.T) { + etcdDirName, err := ioutil.TempDir("", "etcd-headerlookup-test") + check(err) + defer os.RemoveAll(etcdDirName) + clusterInstance := cluster.CreateClusterForTest(etcdDirName) + var mockMap sync.Map + supervisor := supervisor.NewMock( + nil, clusterInstance, mockMap, mockMap, nil, nil, false, nil, nil) + + const validYaml = ` +name: headerLookup +kind: HeaderLookup +headerKey: "X-AUTH-USER" +etcdPrefix: "credentials/" +headerSetters: + - etcdKey: "ext-id" + headerKey: "user-ext-id" +` + unvalidYamls := []string{ + ` +name: headerLookup +kind: HeaderLookup +`, + ` +name: headerLookup +kind: HeaderLookup +headerKey: "X-AUTH-USER" +`, + ` +name: headerLookup +kind: HeaderLookup +headerKey: "X-AUTH-USER" +etcdPrefix: "/credentials/" +`, + ` +name: headerLookup +kind: HeaderLookup +headerKey: "X-AUTH-USER" +etcdPrefix: "/credentials/" +headerSetters: + - etcdKey: "ext-id" +`, + } + + for _, unvalidYaml := range unvalidYamls { + if _, err := createHeaderLookup(unvalidYaml, nil, supervisor); err == nil { + t.Errorf("validate should return error") + } + } + + if _, err := createHeaderLookup(validYaml, nil, supervisor); err != nil { + t.Errorf("validate should not return error: %s", err.Error()) + } +} + +func TestFindKeysToDelete(t *testing.T) { + cache, _ := lru.New(10) + kvs := make(map[string]string) + kvs["doge"] = "headerA: 3\nheaderB: 6" + kvs["foo"] = "headerA: 3\nheaderB: 232" + kvs["bar"] = "headerA: 11\nheaderB: 43" + kvs["key5"] = "headerA: 11\nheaderB: 43" + kvs["key6"] = "headerA: 11\nheaderB: 43" + cache.Add("doge", "headerA: 3\nheaderB: 6") // same values + cache.Add("foo", "headerA: 3\nheaderB: 232") // new value + cache.Add("key4", "---") // new value + cache.Add("key6", "headerA: 11\nheaderB: 44") // new value + if res := findKeysToDelete(kvs, cache); res[0] == "foo" && res[1] == "key4" { + t.Errorf("findModifiedValues failed") + } +} + +func prepareCtxAndHeader() (*contexttest.MockedHTTPContext, http.Header) { + ctx := &contexttest.MockedHTTPContext{} + header := http.Header{} + ctx.MockedRequest.MockedHeader = func() *httpheader.HTTPHeader { + return httpheader.New(header) + } + return ctx, header +} + +func TestHandle(t *testing.T) { + etcdDirName, err := ioutil.TempDir("", "etcd-headerlookup-test") + check(err) + defer os.RemoveAll(etcdDirName) + const config = ` +name: headerLookup +kind: HeaderLookup +headerKey: "X-AUTH-USER" +etcdPrefix: "credentials/" +headerSetters: + - etcdKey: "ext-id" + headerKey: "user-ext-id" +` + clusterInstance := cluster.CreateClusterForTest(etcdDirName) + var mockMap sync.Map + supervisor := supervisor.NewMock( + nil, clusterInstance, mockMap, mockMap, nil, nil, false, nil, nil) + + // let's put data to 'foobar' + clusterInstance.Put("/custom-data/credentials/foobar", + ` +ext-id: 123456789 +extra-entry: "extra" +`) + hl, err := createHeaderLookup(config, nil, supervisor) + check(err) + + // 'foobar' is the id + ctx, header := prepareCtxAndHeader() + + hl.Handle(ctx) // does nothing as header missing + + if header.Get("user-ext-id") != "" { + t.Errorf("header should not be set") + } + + header.Set("X-AUTH-USER", "unknown-user") + + hl.Handle(ctx) // does nothing as user is missing + + if header.Get("user-ext-id") != "" { + t.Errorf("header should be set") + } + + header.Set("X-AUTH-USER", "foobar") + + hl.Handle(ctx) // now updates header + hdr1 := header.Get("user-ext-id") + hl.Handle(ctx) // get from cache + hdr2 := header.Get("user-ext-id") + + if hdr1 != hdr2 || hdr1 != "123456789" { + t.Errorf("header should be set") + } + + // update key-value store + clusterInstance.Put("/custom-data/credentials/foobar", ` +ext-id: 77341 +extra-entry: "extra" +`) + hl, err = createHeaderLookup(config, hl, supervisor) + ctx, header = prepareCtxAndHeader() + header.Set("X-AUTH-USER", "foobar") + + tryCount := 5 + for i := 0; i <= tryCount; i++ { + time.Sleep(200 * time.Millisecond) // wait that cache item gets updated + hl.Handle(ctx) // get updated value + if header.Get("user-ext-id") == "77341" { + break // successfully updated + } else if i == tryCount { + t.Errorf("header should be updated") + } + } + hl, err = createHeaderLookup(config, hl, supervisor) + ctx, header = prepareCtxAndHeader() + header.Set("X-AUTH-USER", "foobar") + // delete foobar completely + clusterInstance.Delete("/custom-data/credentials/foobar") + + for j := 0; j <= tryCount; j++ { + time.Sleep(200 * time.Millisecond) // wait that cache item get deleted + hl.Handle(ctx) // get updated value + if len(header.Get("user-ext-id")) == 0 { + break // successfully deleted + } else if j == tryCount { + t.Errorf("header should be deleted, got %s", header.Get("user-ext-id")) + } + } + + hl.Close() + wg := &sync.WaitGroup{} + wg.Add(1) + clusterInstance.CloseServer(wg) + wg.Wait() +} diff --git a/pkg/filter/validator/basicauth.go b/pkg/filter/validator/basicauth.go new file mode 100644 index 0000000000..181e9e14de --- /dev/null +++ b/pkg/filter/validator/basicauth.go @@ -0,0 +1,359 @@ +/* + * Copyright (c) 2017, MegaEase + * All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package validator + +import ( + "bytes" + "context" + "encoding/base64" + "fmt" + "io" + "strings" + "time" + + "github.com/fsnotify/fsnotify" + "github.com/tg123/go-htpasswd" + "golang.org/x/crypto/bcrypt" + + yaml "gopkg.in/yaml.v2" + + "github.com/megaease/easegress/pkg/cluster" + httpcontext "github.com/megaease/easegress/pkg/context" + "github.com/megaease/easegress/pkg/logger" + "github.com/megaease/easegress/pkg/supervisor" + "github.com/megaease/easegress/pkg/util/httpheader" +) + +type ( + // BasicAuthValidatorSpec defines the configuration of Basic Auth validator. + // There are 'file' and 'etcd' modes. + BasicAuthValidatorSpec struct { + Mode string `yaml:"mode" jsonschema:"omitempty,enum=FILE,enum=ETCD"` + // Required for 'FILE' mode. + // UserFile is path to file containing encrypted user credentials in apache2-utils/htpasswd format. + // To add user `userY`, use `sudo htpasswd /etc/apache2/.htpasswd userY` + // Reference: https://manpages.debian.org/testing/apache2-utils/htpasswd.1.en.html#EXAMPLES + UserFile string `yaml:"userFile" jsonschema:"omitempty"` + // Required for 'ETCD' mode. + // When EtcdPrefix is specified, verify user credentials from etcd. Etcd should store them: + // key: /custom-data/{etcdPrefix}/{$key} + // value: + // key: "$key" + // password: "$password" + EtcdPrefix string `yaml:"etcdPrefix" jsonschema:"omitempty"` + } + + // AuthorizedUsersCache provides cached lookup for authorized users. + AuthorizedUsersCache interface { + Match(string, string) bool + WatchChanges() + Close() + } + + htpasswdUserCache struct { + userFile string + userFileObject *htpasswd.File + watcher *fsnotify.Watcher + syncInterval time.Duration + stopCtx context.Context + cancel context.CancelFunc + } + + etcdUserCache struct { + userFileObject *htpasswd.File + cluster cluster.Cluster + prefix string + syncInterval time.Duration + stopCtx context.Context + cancel context.CancelFunc + } + + // BasicAuthValidator defines the Basic Auth validator + BasicAuthValidator struct { + spec *BasicAuthValidatorSpec + authorizedUsersCache AuthorizedUsersCache + } + + credentials struct { + Key string `yaml:"key" jsonschema:"omitempty"` + Password string `yaml:"password" jsonschema:"omitempty"` + } +) + +const ( + customDataPrefix = "/custom-data/" +) + +func parseCredentials(creds string) (string, string, error) { + parts := strings.Split(creds, ":") + if len(parts) < 2 { + return "", "", fmt.Errorf("bad format") + } + return parts[0], parts[1], nil +} + +func bcryptHash(data []byte) (string, error) { + pw, err := bcrypt.GenerateFromPassword(data, bcrypt.DefaultCost) + return string(pw), err +} + +func newHtpasswdUserCache(userFile string, syncInterval time.Duration) *htpasswdUserCache { + if userFile == "" { + userFile = "/etc/apache2/.htpasswd" + } + stopCtx, cancel := context.WithCancel(context.Background()) + userFileObject, err := htpasswd.New(userFile, htpasswd.DefaultSystems, nil) + if err != nil { + logger.Errorf(err.Error()) + userFileObject = nil + } + watcher, err := fsnotify.NewWatcher() + if err != nil { + logger.Errorf(err.Error()) + watcher = nil + } + return &htpasswdUserCache{ + userFile: userFile, + stopCtx: stopCtx, + cancel: cancel, + watcher: watcher, + userFileObject: userFileObject, + // Removed access or updated passwords are updated according syncInterval. + syncInterval: syncInterval, + } +} + +func (huc *htpasswdUserCache) WatchChanges() { + if huc.userFileObject == nil || huc.watcher == nil { + return + } + go func() { + for { + select { + case _, ok := <-huc.watcher.Events: + if !ok { + return + } + err := huc.userFileObject.Reload(nil) + if err != nil { + logger.Errorf(err.Error()) + } + case err, ok := <-huc.watcher.Errors: + if !ok { + return + } + logger.Errorf(err.Error()) + } + } + }() + err := huc.watcher.Add(huc.userFile) + if err != nil { + logger.Errorf(err.Error()) + } + return +} + +func (huc *htpasswdUserCache) Close() { + if huc.watcher != nil { + huc.watcher.Close() + } +} + +func (huc *htpasswdUserCache) Match(username string, password string) bool { + return huc.userFileObject.Match(username, password) +} + +func newEtcdUserCache(cluster cluster.Cluster, etcdPrefix string) *etcdUserCache { + prefix := customDataPrefix + if etcdPrefix == "" { + prefix += "credentials/" + } else { + prefix = customDataPrefix + strings.TrimPrefix(etcdPrefix, "/") + } + logger.Infof("credentials etcd prefix %s", prefix) + kvs, err := cluster.GetPrefix(prefix) + if err != nil { + logger.Errorf(err.Error()) + return &etcdUserCache{} + } + pwReader := kvsToReader(kvs) + userFileObject, err := htpasswd.NewFromReader(pwReader, htpasswd.DefaultSystems, nil) + if err != nil { + logger.Errorf(err.Error()) + return &etcdUserCache{} + } + stopCtx, cancel := context.WithCancel(context.Background()) + return &etcdUserCache{ + userFileObject: userFileObject, + cluster: cluster, + prefix: prefix, + cancel: cancel, + stopCtx: stopCtx, + // cluster.Syncer updates changes (removed access or updated passwords) immediately. + // syncInterval defines data consistency check interval. + syncInterval: 30 * time.Minute, + } +} + +func kvsToReader(kvs map[string]string) io.Reader { + pwStrSlice := make([]string, 0, len(kvs)) + for _, item := range kvs { + creds := &credentials{} + err := yaml.Unmarshal([]byte(item), creds) + if err != nil { + logger.Errorf(err.Error()) + continue + } + if creds.Key == "" || creds.Password == "" { + logger.Errorf( + "Parsing credential updates failed. Make sure that credentials contains 'key' and 'password' entries.", + ) + continue + } + pwStrSlice = append(pwStrSlice, creds.Key+":"+creds.Password) + } + if len(pwStrSlice) == 0 { + // no credentials found, let's return empty reader + return bytes.NewReader([]byte("")) + } + stringData := strings.Join(pwStrSlice, "\n") + return strings.NewReader(stringData) +} + +func (euc *etcdUserCache) WatchChanges() { + if euc.prefix == "" { + logger.Errorf("missing etcd prefix, skip watching changes") + return + } + var ( + syncer *cluster.Syncer + err error + ch <-chan map[string]string + ) + + for { + syncer, err = euc.cluster.Syncer(euc.syncInterval) + if err != nil { + logger.Errorf("failed to create syncer: %v", err) + } else if ch, err = syncer.SyncPrefix(euc.prefix); err != nil { + logger.Errorf("failed to sync prefix: %v", err) + syncer.Close() + } else { + break + } + + select { + case <-time.After(10 * time.Second): + case <-euc.stopCtx.Done(): + return + } + } + // start listening in background + go func() { + defer syncer.Close() + + for { + select { + case <-euc.stopCtx.Done(): + return + case kvs := <-ch: + logger.Infof("basic auth credentials update") + pwReader := kvsToReader(kvs) + euc.userFileObject.ReloadFromReader(pwReader, nil) + } + } + }() + return +} + +func (euc *etcdUserCache) Close() { + if euc.prefix == "" { + return + } + euc.cancel() +} + +func (euc *etcdUserCache) Match(username string, password string) bool { + if euc.prefix == "" { + return false + } + return euc.userFileObject.Match(username, password) +} + +// NewBasicAuthValidator creates a new Basic Auth validator +func NewBasicAuthValidator(spec *BasicAuthValidatorSpec, supervisor *supervisor.Supervisor) *BasicAuthValidator { + var cache AuthorizedUsersCache + switch spec.Mode { + case "ETCD": + if supervisor == nil || supervisor.Cluster() == nil { + logger.Errorf("BasicAuth validator : failed to read data from etcd") + return nil + } + cache = newEtcdUserCache(supervisor.Cluster(), spec.EtcdPrefix) + case "FILE": + cache = newHtpasswdUserCache(spec.UserFile, 1*time.Minute) + default: + logger.Errorf("BasicAuth validator spec unvalid.") + return nil + } + cache.WatchChanges() + bav := &BasicAuthValidator{ + spec: spec, + authorizedUsersCache: cache, + } + return bav +} + +func parseBasicAuthorizationHeader(hdr *httpheader.HTTPHeader) (string, error) { + const prefix = "Basic " + + tokenStr := hdr.Get("Authorization") + if !strings.HasPrefix(tokenStr, prefix) { + return "", fmt.Errorf("unexpected authorization header: %s", tokenStr) + } + return strings.TrimPrefix(tokenStr, prefix), nil +} + +// Validate validates the Authorization header of a http request +func (bav *BasicAuthValidator) Validate(req httpcontext.HTTPRequest) error { + hdr := req.Header() + base64credentials, err := parseBasicAuthorizationHeader(hdr) + if err != nil { + return err + } + credentialBytes, err := base64.StdEncoding.DecodeString(base64credentials) + if err != nil { + return fmt.Errorf("error occured during base64 decode: %s", err.Error()) + } + credentials := string(credentialBytes) + userID, password, err := parseCredentials(credentials) + if err != nil { + return fmt.Errorf("unauthorized") + } + + if bav.authorizedUsersCache.Match(userID, password) { + req.Header().Set("X-AUTH-USER", userID) + return nil + } + return fmt.Errorf("unauthorized") +} + +// Close closes authorizedUsersCache. +func (bav *BasicAuthValidator) Close() { + bav.authorizedUsersCache.Close() +} diff --git a/pkg/filter/validator/validator.go b/pkg/filter/validator/validator.go index c59636cf27..f32ec0fe56 100644 --- a/pkg/filter/validator/validator.go +++ b/pkg/filter/validator/validator.go @@ -20,6 +20,8 @@ package validator import ( "net/http" + "fmt" + "github.com/megaease/easegress/pkg/context" "github.com/megaease/easegress/pkg/object/httppipeline" "github.com/megaease/easegress/pkg/util/httpheader" @@ -46,10 +48,11 @@ type ( filterSpec *httppipeline.FilterSpec spec *Spec - headers *httpheader.Validator - jwt *JWTValidator - signer *signer.Signer - oauth2 *OAuth2Validator + headers *httpheader.Validator + jwt *JWTValidator + signer *signer.Signer + oauth2 *OAuth2Validator + basicAuth *BasicAuthValidator } // Spec describes the Validator. @@ -58,9 +61,18 @@ type ( JWT *JWTValidatorSpec `yaml:"jwt,omitempty" jsonschema:"omitempty"` Signature *signer.Spec `yaml:"signature,omitempty" jsonschema:"omitempty"` OAuth2 *OAuth2ValidatorSpec `yaml:"oauth2,omitempty" jsonschema:"omitempty"` + BasicAuth *BasicAuthValidatorSpec `yaml:"basicAuth,omitempty" jsonschema:"omitempty"` } ) +// Validate verifies that at least one of the validations is defined. +func (spec Spec) Validate() error { + if spec == (Spec{}) { + return fmt.Errorf("none of the validations are defined") + } + return nil +} + // Kind returns the kind of Validator. func (v *Validator) Kind() string { return Kind @@ -97,18 +109,18 @@ func (v *Validator) reload() { if v.spec.Headers != nil { v.headers = httpheader.NewValidator(v.spec.Headers) } - if v.spec.JWT != nil { v.jwt = NewJWTValidator(v.spec.JWT) } - if v.spec.Signature != nil { v.signer = signer.CreateFromSpec(v.spec.Signature) } - if v.spec.OAuth2 != nil { v.oauth2 = NewOAuth2Validator(v.spec.OAuth2) } + if v.spec.BasicAuth != nil { + v.basicAuth = NewBasicAuthValidator(v.spec.BasicAuth, v.filterSpec.Super()) + } } // Handle validates HTTPContext. @@ -120,38 +132,38 @@ func (v *Validator) Handle(ctx context.HTTPContext) string { func (v *Validator) handle(ctx context.HTTPContext) string { req := ctx.Request() + prepareErrorResponse := func(status int, tagPrefix string, err error) { + ctx.Response().SetStatusCode(status) + ctx.AddTag(stringtool.Cat(tagPrefix, err.Error())) + } + if v.headers != nil { - err := v.headers.Validate(req.Header()) - if err != nil { - ctx.Response().SetStatusCode(http.StatusBadRequest) - ctx.AddTag(stringtool.Cat("header validator: ", err.Error())) + if err := v.headers.Validate(req.Header()); err != nil { + prepareErrorResponse(http.StatusBadRequest, "header validator: ", err) return resultInvalid } } - if v.jwt != nil { - err := v.jwt.Validate(req) - if err != nil { - ctx.Response().SetStatusCode(http.StatusUnauthorized) - ctx.AddTag(stringtool.Cat("JWT validator: ", err.Error())) + if err := v.jwt.Validate(req); err != nil { + prepareErrorResponse(http.StatusUnauthorized, "JWT validator: ", err) return resultInvalid } } - if v.signer != nil { - err := v.signer.Verify(req.Std()) - if err != nil { - ctx.Response().SetStatusCode(http.StatusUnauthorized) - ctx.AddTag(stringtool.Cat("signature validator: ", err.Error())) + if err := v.signer.Verify(req.Std()); err != nil { + prepareErrorResponse(http.StatusUnauthorized, "signature validator: ", err) return resultInvalid } } - if v.oauth2 != nil { - err := v.oauth2.Validate(req) - if err != nil { - ctx.Response().SetStatusCode(http.StatusUnauthorized) - ctx.AddTag(stringtool.Cat("oauth2 validator: ", err.Error())) + if err := v.oauth2.Validate(req); err != nil { + prepareErrorResponse(http.StatusUnauthorized, "oauth2 validator: ", err) + return resultInvalid + } + } + if v.basicAuth != nil { + if err := v.basicAuth.Validate(req); err != nil { + prepareErrorResponse(http.StatusUnauthorized, "http basic validator: ", err) return resultInvalid } } @@ -162,5 +174,9 @@ func (v *Validator) handle(ctx context.HTTPContext) string { // Status returns status. func (v *Validator) Status() interface{} { return nil } -// Close closes Validator. -func (v *Validator) Close() {} +// Close closes validations. +func (v *Validator) Close() { + if v.basicAuth != nil { + v.basicAuth.Close() + } +} diff --git a/pkg/filter/validator/validator_test.go b/pkg/filter/validator/validator_test.go index 054abb9811..0d569b082e 100644 --- a/pkg/filter/validator/validator_test.go +++ b/pkg/filter/validator/validator_test.go @@ -18,16 +18,22 @@ package validator import ( + "encoding/base64" "fmt" "io" + "io/ioutil" "net/http" "os" "strings" + "sync" "testing" + "time" + cluster "github.com/megaease/easegress/pkg/cluster" "github.com/megaease/easegress/pkg/context/contexttest" "github.com/megaease/easegress/pkg/logger" "github.com/megaease/easegress/pkg/object/httppipeline" + "github.com/megaease/easegress/pkg/supervisor" "github.com/megaease/easegress/pkg/util/httpheader" "github.com/megaease/easegress/pkg/util/yamltool" ) @@ -38,10 +44,13 @@ func TestMain(m *testing.M) { os.Exit(code) } -func createValidator(yamlSpec string, prev *Validator) *Validator { +func createValidator(yamlSpec string, prev *Validator, supervisor *supervisor.Supervisor) *Validator { rawSpec := make(map[string]interface{}) yamltool.Unmarshal([]byte(yamlSpec), &rawSpec) - spec, _ := httppipeline.NewFilterSpec(rawSpec, nil) + spec, err := httppipeline.NewFilterSpec(rawSpec, supervisor) + if err != nil { + panic(err.Error()) + } v := &Validator{} if prev == nil { v.Init(spec) @@ -61,7 +70,7 @@ headers: regexp: "^ok-.+$" ` - v := createValidator(yamlSpec, nil) + v := createValidator(yamlSpec, nil, nil) header := http.Header{} ctx := &contexttest.MockedHTTPContext{} @@ -102,7 +111,7 @@ jwt: algorithm: HS256 secret: 313233343536 ` - v := createValidator(yamlSpec, nil) + v := createValidator(yamlSpec, nil, nil) ctx := &contexttest.MockedHTTPContext{} ctx.MockedRequest.MockedCookie = func(name string) (*http.Cookie, error) { @@ -148,7 +157,7 @@ jwt: t.Errorf("the jwt token in cookie should be valid") } - v = createValidator(yamlSpec, v) + v = createValidator(yamlSpec, v, nil) result = v.Handle(ctx) if result == resultInvalid { t.Errorf("the jwt token in cookie should be valid") @@ -169,7 +178,7 @@ oauth2: algorithm: HS256 secret: 313233343536 ` - v := createValidator(yamlSpec, nil) + v := createValidator(yamlSpec, nil, nil) ctx := &contexttest.MockedHTTPContext{} @@ -216,7 +225,7 @@ oauth2: clientId: megaease clientSecret: secret ` - v := createValidator(yamlSpec, nil) + v := createValidator(yamlSpec, nil, nil) ctx := &contexttest.MockedHTTPContext{} header := http.Header{} @@ -253,7 +262,7 @@ oauth2: clientSecret: secret basicAuth: megaease@megaease ` - v = createValidator(yamlSpec, nil) + v = createValidator(yamlSpec, nil, nil) body = `{ "subject":"megaease.com", @@ -276,7 +285,7 @@ signature: accessKeys: AKID: SECRET ` - v := createValidator(yamlSpec, nil) + v := createValidator(yamlSpec, nil, nil) ctx := &contexttest.MockedHTTPContext{} ctx.MockedRequest.MockedStd = func() *http.Request { @@ -289,3 +298,226 @@ signature: t.Errorf("OAuth/2 Authorization should fail") } } + +func check(e error) { + if e != nil { + panic(e) + } +} + +func prepareCtxAndHeader() (*contexttest.MockedHTTPContext, http.Header) { + ctx := &contexttest.MockedHTTPContext{} + header := http.Header{} + ctx.MockedRequest.MockedHeader = func() *httpheader.HTTPHeader { + return httpheader.New(header) + } + return ctx, header +} + +func cleanFile(userFile *os.File) { + err := userFile.Truncate(0) + check(err) + _, err = userFile.Seek(0, 0) + check(err) + userFile.Write([]byte("")) +} + +func TestBasicAuth(t *testing.T) { + userIds := []string{ + "userY", "userZ", "nonExistingUser", + } + passwords := []string{ + "userpasswordY", "userpasswordZ", "userpasswordX", + } + encrypt := func(pw string) string { + encPw, err := bcryptHash([]byte(pw)) + check(err) + return encPw + } + encryptedPasswords := []string{ + encrypt("userpasswordY"), encrypt("userpasswordZ"), encrypt("userpasswordX"), + } + + t.Run("unexisting userFile", func(t *testing.T) { + yamlSpec := ` +kind: Validator +name: validator +basicAuth: + mode: FILE + userFile: unexisting-file` + v := createValidator(yamlSpec, nil, nil) + ctx, _ := prepareCtxAndHeader() + if v.Handle(ctx) != resultInvalid { + t.Errorf("should be invalid") + } + }) + t.Run("credentials from userFile", func(t *testing.T) { + userFile, err := os.CreateTemp("", "apache2-htpasswd") + check(err) + + yamlSpec := ` +kind: Validator +name: validator +basicAuth: + mode: FILE + userFile: ` + userFile.Name() + + // test invalid format + userFile.Write([]byte("keypass")) + v := createValidator(yamlSpec, nil, nil) + ctx, _ := prepareCtxAndHeader() + if v.Handle(ctx) != resultInvalid { + t.Errorf("should be invalid") + } + + // now proper format + cleanFile(userFile) + userFile.Write( + []byte(userIds[0] + ":" + encryptedPasswords[0] + "\n" + userIds[1] + ":" + encryptedPasswords[1])) + expectedValid := []bool{true, true, false} + + v = createValidator(yamlSpec, nil, nil) + for i := 0; i < 3; i++ { + ctx, header := prepareCtxAndHeader() + b64creds := base64.StdEncoding.EncodeToString([]byte(userIds[i] + ":" + passwords[i])) + header.Set("Authorization", "Basic "+b64creds) + result := v.Handle(ctx) + if expectedValid[i] { + if result == resultInvalid { + t.Errorf("should be authorized") + } + } else { + if result != resultInvalid { + t.Errorf("should be unauthorized") + } + } + } + + cleanFile(userFile) // no more authorized users + + tryCount := 5 + for i := 0; i <= tryCount; i++ { + time.Sleep(200 * time.Millisecond) // wait that cache item gets deleted + ctx, header := prepareCtxAndHeader() + b64creds := base64.StdEncoding.EncodeToString([]byte(userIds[0] + ":" + passwords[0])) + header.Set("Authorization", "Basic "+b64creds) + result := v.Handle(ctx) + if result == resultInvalid { + break // successfully unauthorized + } + if i == tryCount && result != resultInvalid { + t.Errorf("should be unauthorized") + } + } + + os.Remove(userFile.Name()) + v.Close() + }) + + t.Run("test kvsToReader", func(t *testing.T) { + kvs := make(map[string]string) + kvs["/creds/key1"] = "key: key1\npass: pw" // invalid + kvs["/creds/key2"] = "ky: key2\npassword: pw" // invalid + kvs["/creds/key3"] = "key: key3\npassword: pw" // valid + reader := kvsToReader(kvs) + b, err := io.ReadAll(reader) + check(err) + s := string(b) + if s != "key3:pw" { + t.Errorf("parsing failed, %s", s) + } + }) + + t.Run("credentials from etcd", func(t *testing.T) { + etcdDirName, err := ioutil.TempDir("", "etcd-validator-test") + check(err) + defer os.RemoveAll(etcdDirName) + clusterInstance := cluster.CreateClusterForTest(etcdDirName) + + // Test newEtcdUserCache + if euc := newEtcdUserCache(clusterInstance, ""); euc.prefix != "/custom-data/credentials/" { + t.Errorf("newEtcdUserCache failed") + } + if euc := newEtcdUserCache(clusterInstance, "/extra-slash/"); euc.prefix != "/custom-data/extra-slash/" { + t.Errorf("newEtcdUserCache failed") + } + + pwToYaml := func(user string, pw string) string { + return fmt.Sprintf("key: %s\npassword: %s", user, pw) + } + clusterInstance.Put("/custom-data/credentials/1", pwToYaml(userIds[0], encryptedPasswords[0])) + clusterInstance.Put("/custom-data/credentials/2", pwToYaml(userIds[2], encryptedPasswords[2])) + + var mockMap sync.Map + supervisor := supervisor.NewMock( + nil, clusterInstance, mockMap, mockMap, nil, nil, false, nil, nil) + + yamlSpec := ` +kind: Validator +name: validator +basicAuth: + mode: ETCD + etcdPrefix: credentials/ +` + expectedValid := []bool{true, false, true} + v := createValidator(yamlSpec, nil, supervisor) + for i := 0; i < 3; i++ { + ctx, header := prepareCtxAndHeader() + b64creds := base64.StdEncoding.EncodeToString([]byte(userIds[i] + ":" + passwords[i])) + header.Set("Authorization", "Basic "+b64creds) + result := v.Handle(ctx) + if expectedValid[i] { + if result == resultInvalid { + t.Errorf("should be authorized") + } + } else { + if result != resultInvalid { + t.Errorf("should be unauthorized") + } + } + } + + clusterInstance.Delete("/custom-data/credentials/1") // first user is not authorized anymore + clusterInstance.Put("/custom-data/credentials/doge", + ` +randomEntry1: 21 +nestedEntry: + key1: val1 +password: doge +key: doge +lastEntry: "byebye" +`) + + tryCount := 5 + for i := 0; i <= tryCount; i++ { + time.Sleep(200 * time.Millisecond) // wait that cache item gets deleted + ctx, header := prepareCtxAndHeader() + b64creds := base64.StdEncoding.EncodeToString([]byte(userIds[0] + ":" + passwords[0])) + header.Set("Authorization", "Basic "+b64creds) + result := v.Handle(ctx) + if result == resultInvalid { + break // successfully unauthorized + } + if i == tryCount && result != resultInvalid { + t.Errorf("should be unauthorized") + } + } + + ctx, header := prepareCtxAndHeader() + b64creds := base64.StdEncoding.EncodeToString([]byte("doge:doge")) + header.Set("Authorization", "Basic "+b64creds) + result := v.Handle(ctx) + if result == resultInvalid { + t.Errorf("should be authorized") + } + if header.Get("X-AUTH-USER") != "doge" { + t.Errorf("x-auth-user header not set") + } + + v.Close() + wg := &sync.WaitGroup{} + wg.Add(1) + clusterInstance.CloseServer(wg) + wg.Wait() + }) +} diff --git a/pkg/registry/registry.go b/pkg/registry/registry.go index 0b962e4112..0277ee5758 100644 --- a/pkg/registry/registry.go +++ b/pkg/registry/registry.go @@ -26,6 +26,7 @@ import ( _ "github.com/megaease/easegress/pkg/filter/connectcontrol" _ "github.com/megaease/easegress/pkg/filter/corsadaptor" _ "github.com/megaease/easegress/pkg/filter/fallback" + _ "github.com/megaease/easegress/pkg/filter/headerlookup" _ "github.com/megaease/easegress/pkg/filter/kafka" _ "github.com/megaease/easegress/pkg/filter/meshadaptor" _ "github.com/megaease/easegress/pkg/filter/mock"