diff --git a/plugins/wasm-go/extensions/basic-auth/README.md b/plugins/wasm-go/extensions/basic-auth/README.md new file mode 100644 index 000000000..e8b37b674 --- /dev/null +++ b/plugins/wasm-go/extensions/basic-auth/README.md @@ -0,0 +1,114 @@ +--- +title: Basic 认证 +keywords: [higress,basic auth] +description: Basic 认证插件配置参考 +--- + +## 功能说明 +`basic-auth`插件实现了基于 HTTP Basic Auth 标准进行认证鉴权的功能 + +## 运行属性 + +插件执行阶段:`认证阶段` +插件执行优先级:`320` + +## 配置字段 + +### 全局配置 + +| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 | +| ----------- | --------------- | -------- | ------ | ---------------------------------------------------- | +| `consumers` | array of object | 必填 | - | 配置服务的调用者,用于对请求进行认证 | +| `global_auth` | bool | 选填 | - | 若配置为true,则全局生效认证机制; 若配置为false,则只对做了配置的域名和路由生效认证机制; 若不配置则仅当没有域名和路由配置时全局生效(兼容机制) | + +`consumers`中每一项的配置字段说明如下: + +| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 | +| ------------ | -------- | -------- | ------ | ------------------------ | +| `credential` | string | 必填 | - | 配置该consumer的访问凭证 | +| `name` | string | 必填 | - | 配置该consumer的名称 | + +### 域名和路由级配置 + +| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 | +| ---------------- | --------------- | ------------------------------------------------- | ------ | -------------------------------------------------- | +| `allow` | array of string | 必填 | - | 对于符合匹配条件的请求,配置允许访问的consumer名称 | + +**注意:** +- 对于通过认证鉴权的请求,请求的header会被添加一个`X-Mse-Consumer`字段,用以标识调用者的名称。 + +## 配置示例 + +### 对特定路由或域名开启认证和鉴权 + +以下配置将对网关特定路由或域名开启 Basic Auth 认证和鉴权,注意凭证信息中的用户名和密码之间使用":"分隔,`credential`字段不能重复 + +**全局配置** + +```yaml +consumers: +- credential: 'admin:123456' + name: consumer1 +- credential: 'guest:abc' + name: consumer2 +global_auth: false +``` + + +**路由级配置** + +对 route-a 和 route-b 这两个路由做如下配置: + +```yaml +allow: +- consumer1 +``` + +对 *.example.com 和 test.com 在这两个域名做如下配置: + +```yaml +allow: +- consumer2 +``` + +若是在控制台进行配置,此例指定的 `route-a` 和 `route-b` 即在控制台创建路由时填写的路由名称,当匹配到这两个路由时,将允许`name`为`consumer1`的调用者访问,其他调用者不允许访问; + +此例指定的 `*.example.com` 和 `test.com` 用于匹配请求的域名,当发现域名匹配时,将允许`name`为`consumer2`的调用者访问,其他调用者不允许访问。 + +#### 根据该配置,下列请求可以允许访问: + +**请求指定用户名密码** + +```bash +# 假设以下请求将会匹配到route-a路由 +# 使用 curl 的 -u 参数指定 +curl -u admin:123456 http://xxx.hello.com/test +# 或者直接指定 Authorization 请求头,用户名密码使用 base64 编码 +curl -H 'Authorization: Basic YWRtaW46MTIzNDU2' http://xxx.hello.com/test +``` + +认证鉴权通过后,请求的header中会被添加一个`X-Mse-Consumer`字段,在此例中其值为`consumer1`,用以标识调用方的名称 + +#### 下列请求将拒绝访问: + +**请求未提供用户名密码,返回401** +```bash +curl http://xxx.hello.com/test +``` +**请求提供的用户名密码错误,返回401** +```bash +curl -u admin:abc http://xxx.hello.com/test +``` +**根据请求的用户名和密码匹配到的调用者无访问权限,返回403** +```bash +# consumer2不在route-a的allow列表里 +curl -u guest:abc http://xxx.hello.com/test +``` + +## 相关错误码 + +| HTTP 状态码 | 出错信息 | 原因说明 | +| ----------- |--------------------------------------------------------------------------------| ---------------------- | +| 401 | Request denied by Basic Auth check. No Basic Authentication information found. | 请求未提供凭证 | +| 401 | Request denied by Basic Auth check. Invalid username and/or password. | 请求凭证无效 | +| 403 | Request denied by Basic Auth check. Unauthorized consumer. | 请求的调用方无访问权限 | \ No newline at end of file diff --git a/plugins/wasm-go/extensions/basic-auth/README_EN.md b/plugins/wasm-go/extensions/basic-auth/README_EN.md new file mode 100644 index 000000000..880f961ed --- /dev/null +++ b/plugins/wasm-go/extensions/basic-auth/README_EN.md @@ -0,0 +1,119 @@ +--- +title: Basic Auth +keywords: [higress, basic auth] +description: Basic authentication plug-in configuration reference +--- + +## Description +`basic-auth` plugin implements the function of authentication based on the HTTP Basic Auth standard. + +## Configuration Fields + +| Name | Type | Requirement | Default Value | Description | +| ----------- | --------------- | -------- | ------ | ---------------------------------------------------- | +| `consumers` | array of object | Required | - | Caller of the service for authentication of requests | +| `_rules_` | array of object | Optional | - | Configure access permission list for specific routes or domains to authenticate requests | + +Filed descriptions of `consumers` items: + +| Name | Type | Requirement | Default Value | Description | +| ------------ | ------ | ----------- | ------------- | ------------------------------------- | +| `credential` | string | Required | - | Credential for this consumer's access | +| `name` | string | Required | - | Name of this consumer | + +Configuration field descriptions for each item in `_rules_` are as follows: + +| Field Name | Data Type | Requirement | Default | Description | +| ---------------- | --------------- | ------------------------------------------------- | ------ | -------------------------------------------------- | +| `_match_route_` | array of string | One of `_match_route_` or `_match_domain_` | - | Configure the routes to match for request authorization | +| `_match_domain_` | array of string | One of `_match_route_` , `_match_domain_` | - | Configure the domains to match for request authorization | +| `allow` | array of string | Required | - | Configure the consumer names allowed to access requests that match the match condition | + +**Note:** + +- If the `_rules_` field is not configured, authentication is enabled for all routes of the current gateway instance by default; +- For authenticated requests, `X-Mse-Consumer` field will be added to the request header to identify the name of the caller. + +## Configuration Samples + +### Enable Authentication and Authorization for specific routes or domains + +The following configuration will enable Basic Auth authentication and authorization for specific routes or domains of the gateway. Note that the username and password in the credential information are separated by a ":", and the `credential` field cannot be repeated. + + + +```yaml +# use the _rules_ field for fine-grained rule configuration. +consumers: +- credential: 'admin:123456' + name: consumer1 +- credential: 'guest:abc' + name: consumer2 +_rules_: +# rule 1: match by the route name. + - _match_route_: + - route-a + - route-b + allow: + - consumer1 +# rule 2: match by the domain. + - _match_domain_: + - "*.example.com" + - test.com + allow: + - consumer2 +``` +In this sample, `route-a` and `route-b` specified in `_match_route_` are the route names filled in when creating gateway routes. When these two routes are matched, the caller with `name` as `consumer1` is allowed to access, and other callers are not allowed to access. + +The `*.example.com` and `test.com` specified in `_match_domain_` are used to match the domain name of the request. When the domain name is matched, the caller with `name` as `consumer2` is allowed to access, and other callers are not allowed to access. + + +#### According to this configuration, the following requests are allowed: + +**Requests with specified username and password** + +```bash +# Assuming the following request will match with route-a +# Use -u option of curl to specify the credentials +curl -u admin:123456 http://xxx.hello.com/test +# Or specify the Authorization request header directly with the credentials in base64 encoding +curl -H 'Authorization: Basic YWRtaW46MTIzNDU2' http://xxx.hello.com/test +``` + +A `X-Mse-Consumer` field will be added to the headers of the request, and its value in this example is `consumer1`, used to identify the name of the caller when passed authentication and authorization. + +#### The following requests will be denied: + +**Requests without providing username and password, returning 401** +```bash +curl http://xxx.hello.com/test +``` +**Requests with incorrect username or password, returning 401** +```bash +curl -u admin:abc http://xxx.hello.com/test +``` +**Requests matched with a caller who has no access permission, returning 403** +```bash +# consumer2 is not in the allow list of route-a +curl -u guest:abc http://xxx.hello.com/test +``` + +### Enable basic auth for gateway instance + +The following configuration does not specify the `_rules_` field, so Basic Auth authentication will be effective for the whole gateway instance. + +```yaml +consumers: +- credential: 'admin:123456' + name: consumer1 +- credential: 'guest:abc' + name: consumer2 +``` + +## Error Codes + +| HTTP Status Code | Error Info | Reason | +| ----------- |--------------------------------------------------------------------------------| ---------------------- | +| 401 | Request denied by Basic Auth check. No Basic Authentication information found. | Credentials not provided in the request | +| 401 | Request denied by Basic Auth check. Invalid username and/or password. | Invalid username and/or password | +| 403 | Request denied by Basic Auth check. Unauthorized consumer. | Unauthorized consumer | \ No newline at end of file diff --git a/plugins/wasm-go/extensions/basic-auth/VERSION b/plugins/wasm-go/extensions/basic-auth/VERSION new file mode 100644 index 000000000..afaf360d3 --- /dev/null +++ b/plugins/wasm-go/extensions/basic-auth/VERSION @@ -0,0 +1 @@ +1.0.0 \ No newline at end of file diff --git a/plugins/wasm-go/extensions/basic-auth/go.mod b/plugins/wasm-go/extensions/basic-auth/go.mod new file mode 100644 index 000000000..5bfa3d86d --- /dev/null +++ b/plugins/wasm-go/extensions/basic-auth/go.mod @@ -0,0 +1,22 @@ +module github.com/alibaba/higress/plugins/wasm-go/extensions/basic-auth + +go 1.19 + + +replace github.com/alibaba/higress/plugins/wasm-go => ../.. + + +require ( + github.com/alibaba/higress/plugins/wasm-go v0.0.0 + github.com/pkg/errors v0.9.1 + github.com/tetratelabs/proxy-wasm-go-sdk v0.22.0 + github.com/tidwall/gjson v1.14.3 +) + +require ( + github.com/google/uuid v1.3.0 // indirect + github.com/magefile/mage v1.14.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.0 // indirect + github.com/wasilibs/nottinygc v0.3.0 // indirect +) diff --git a/plugins/wasm-go/extensions/basic-auth/go.sum b/plugins/wasm-go/extensions/basic-auth/go.sum new file mode 100644 index 000000000..16b8e8332 --- /dev/null +++ b/plugins/wasm-go/extensions/basic-auth/go.sum @@ -0,0 +1,9 @@ +github.com/WeixinX/higress/plugins/wasm-go v0.0.0-20230911073755-f281286d0cdb/go.mod h1:shD9qvrDS6xklAVjKYho8kHIVdW4A1vhNEOAL2miEEE= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/tetratelabs/proxy-wasm-go-sdk v0.22.0/go.mod h1:qkW5MBz2jch2u8bS59wws65WC+Gtx3x0aPUX5JL7CXI= +github.com/tidwall/gjson v1.14.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/wasilibs/nottinygc v0.3.0/go.mod h1:oDcIotskuYNMpqMF23l7Z8uzD4TC0WXHK8jetlB3HIo= diff --git a/plugins/wasm-go/extensions/basic-auth/main.go b/plugins/wasm-go/extensions/basic-auth/main.go new file mode 100644 index 000000000..e739691bb --- /dev/null +++ b/plugins/wasm-go/extensions/basic-auth/main.go @@ -0,0 +1,330 @@ +// Copyright (c) 2022 Alibaba Group Holding Ltd. +// +// 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. + +// The 'Basic' HTTP Authentication Scheme: https://datatracker.ietf.org/doc/html/rfc7617 + +package main + +import ( + "encoding/base64" + "fmt" + "strings" + + "github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper" + + "github.com/pkg/errors" + "github.com/tetratelabs/proxy-wasm-go-sdk/proxywasm" + "github.com/tetratelabs/proxy-wasm-go-sdk/proxywasm/types" + "github.com/tidwall/gjson" +) + +func main() { + wrapper.SetCtx( + "basic-auth", + wrapper.ParseOverrideConfigBy(parseGlobalConfig, parseOverrideRuleConfig), + wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders), + ) +} + +// @Name basic-auth +// @Category auth +// @Phase AUTHN +// @Priority 320 +// @Title zh-CN Basic Auth +// @Description zh-CN 本插件实现了基于 HTTP Basic Auth 标准进行认证鉴权的功能。 +// @Description en-US This plugin implements an authentication function based on HTTP Basic Auth standard. +// @IconUrl https://img.alicdn.com/imgextra/i4/O1CN01BPFGlT1pGZ2VDLgaH_!!6000000005333-2-tps-42-42.png +// @Version 1.0.0 +// +// @Contact.name Higress Team +// @Contact.url http://higress.io/ +// @Contact.email admin@higress.io +// +// @Example +// global_auth: false +// consumers: +// - name: consumer1 +// credential: admin:123456 +// - name: consumer2 +// credential: guest:abc +// +// @End +type BasicAuthConfig struct { + // @Title 是否开启全局认证 + // @Title en-US Enable Global Auth + // @Description 若不开启全局认证,则全局配置只提供凭证信息。只有在域名或路由上进行了配置才会启用认证。 + // @Description en-US If set to false, only consumer info will be accepted from the global config. Auth feature shall only be enabled if the corresponding domain or route is configured. + // @Scope GLOBAL + globalAuth *bool `yaml:"global_auth"` + + // @Title 调用方列表 + // @Title en-US Consumer List + // @Description 服务调用方列表,用于对请求进行认证。 + // @Description en-US List of service consumers which will be used in request authentication. + // @Scope GLOBAL + consumers []Consumer `yaml:"consumers"` + + // @Title 授权访问的调用方列表 + // @Title en-US Allowed Consumers + // @Description 对于匹配上述条件的请求,允许访问的调用方列表。 + // @Description en-US Consumers to be allowed for matched requests. + allow []string `yaml:"allow"` + + credential2Name map[string]string `yaml:"-"` + username2Passwd map[string]string `yaml:"-"` +} + +type Consumer struct { + // @Title 名称 + // @Title en-US Name + // @Description 该调用方的名称。 + // @Description en-US The name of the consumer. + name string `yaml:"name"` + + // @Title 访问凭证 + // @Title en-US Credential + // @Description 该调用方的访问凭证。 + // @Description en-US The credential of the consumer. + // @Scope GLOBAL + credential string `yaml:"credential"` +} + +var ( + ruleSet bool // 插件是否至少在一个 domain 或 route 上生效 + protectionSpace = "MSE Gateway" // 认证失败时,返回响应头 WWW-Authenticate: Basic realm=MSE Gateway +) + +func parseGlobalConfig(json gjson.Result, global *BasicAuthConfig, log wrapper.Log) error { + // log.Debug("global config") + ruleSet = false + global.credential2Name = make(map[string]string) + global.username2Passwd = make(map[string]string) + + consumers := json.Get("consumers") + if !consumers.Exists() { + return errors.New("consumers is required") + } + if len(consumers.Array()) == 0 { + return errors.New("consumers cannot be empty") + } + + for _, item := range consumers.Array() { + name := item.Get("name") + if !name.Exists() || name.String() == "" { + return errors.New("consumer name is required") + } + credential := item.Get("credential") + if !credential.Exists() || credential.String() == "" { + return errors.New("consumer credential is required") + } + if _, ok := global.credential2Name[credential.String()]; ok { + return errors.Errorf("duplicate consumer credential: %s", credential.String()) + } + userAndPasswd := strings.Split(credential.String(), ":") + if len(userAndPasswd) != 2 { + return errors.Errorf("invalid credential format: %s", credential.String()) + } + + consumer := Consumer{ + name: name.String(), + credential: credential.String(), + } + global.consumers = append(global.consumers, consumer) + global.credential2Name[consumer.credential] = consumer.name + global.username2Passwd[userAndPasswd[0]] = userAndPasswd[1] + } + + globalAuth := json.Get("global_auth") + if globalAuth.Exists() { + ga := globalAuth.Bool() + global.globalAuth = &ga + } + + return nil +} + +func parseOverrideRuleConfig(json gjson.Result, global BasicAuthConfig, config *BasicAuthConfig, log wrapper.Log) error { + log.Debug("domain/route config") + // override config via global + *config = global + + allow := json.Get("allow") + if !allow.Exists() { + return errors.New("allow is required") + } + if len(allow.Array()) == 0 { + return errors.New("allow cannot be empty") + } + + for _, item := range allow.Array() { + config.allow = append(config.allow, item.String()) + } + ruleSet = true + + return nil +} + +// basic-auth 插件认证逻辑: +// - global_auth == true 开启全局生效: +// - 若当前 domain/route 未配置 allow 列表,即未配置该插件:则在所有 consumers 中查找,如果找到则认证通过,否则认证失败 (1*) +// - 若当前 domain/route 配置了该插件:则在 allow 列表中查找,如果找到则认证通过,否则认证失败 +// +// - global_auth == false 非全局生效:(2*) +// - 若当前 domain/route 未配置该插件:则直接放行 +// - 若当前 domain/route 配置了该插件:则在 allow 列表中查找,如果找到则认证通过,否则认证失败 +// +// - global_auth 未设置: +// - 若没有一个 domain/route 配置该插件:则遵循 (1*) +// - 若有至少一个 domain/route 配置该插件:则遵循 (2*) +func onHttpRequestHeaders(ctx wrapper.HttpContext, config BasicAuthConfig, log wrapper.Log) types.Action { + var ( + noAllow = len(config.allow) == 0 // 未配置 allow 列表,表示插件在该 domain/route 未生效 + globalAuthNoSet = config.globalAuth == nil + globalAuthSetTrue = !globalAuthNoSet && *config.globalAuth + globalAuthSetFalse = !globalAuthNoSet && !*config.globalAuth + ) + // log.Debugf("global auth set: %t", !globalAuthNoSet) + // log.Debugf("rule set: %t", ruleSet) + // log.Debugf("config: %+v", config) + + // 不需要认证而直接放行的情况: + // - global_auth == false 且 当前 domain/route 未配置该插件 + // - global_auth 未设置 且 有至少一个 domain/route 配置该插件 且 当前 domain/route 未配置该插件 + if globalAuthSetFalse || (globalAuthNoSet && ruleSet) { + if noAllow { + log.Info("authorization is not required") + return types.ActionContinue + } + } + + // 以下为需要认证的情况: + auth, err := proxywasm.GetHttpRequestHeader("Authorization") + if err != nil { + log.Warnf("failed to get authorization: %v", err) + return deniedNoBasicAuthData() + } + if auth == "" { + log.Warnf("authorization is empty") + return deniedNoBasicAuthData() + } + if !strings.HasPrefix(auth, "Basic ") { + log.Warnf("authorization has no prefix 'Basic '") + return deniedNoBasicAuthData() + } + + encodedCredential := strings.TrimPrefix(auth, "Basic ") + credentialByte, err := base64.StdEncoding.DecodeString(encodedCredential) + if err != nil { + log.Warnf("failed to decode authorization %q: %v", string(credentialByte), err) + return deniedInvalidCredentials() + } + + credential := string(credentialByte) + userAndPasswd := strings.Split(credential, ":") + if len(userAndPasswd) != 2 { + log.Warnf("invalid credential format: %s", credential) + return deniedInvalidCredentials() + } + + user, passwd := userAndPasswd[0], userAndPasswd[1] + if correctPasswd, ok := config.username2Passwd[user]; !ok { + log.Warnf("credential username %q is not configured", user) + return deniedInvalidCredentials() + } else { + if passwd != correctPasswd { + log.Warnf("credential password is not correct for username %q", user) + return deniedInvalidCredentials() + } + } + + // 以下为 username 和 password 正确的情况: + name, ok := config.credential2Name[credential] + if !ok { // 理论上该分支永远不可达,因为 username 和 password 都是从 credential 中获取的 + log.Warnf("credential %q is not configured", credential) + return deniedUnauthorizedConsumer() + } + + // 全局生效: + // - global_auth == true 且 当前 domain/route 未配置该插件 + // - global_auth 未设置 且 没有任何一个 domain/route 配置该插件 + if (globalAuthSetTrue && noAllow) || (globalAuthNoSet && !ruleSet) { + // log.Debug("authenticated case 1") + log.Infof("consumer %q authenticated", name) + return authenticated(name) + } + + // 全局生效,但当前 domain/route 配置了 allow 列表 + if globalAuthSetTrue && !noAllow { + if !contains(config.allow, name) { + log.Warnf("consumer %q is not allowed", name) + return deniedUnauthorizedConsumer() + } + // log.Debug("authenticated case 2") + log.Infof("consumer %q authenticated", name) + return authenticated(name) + } + + // 非全局生效 + if globalAuthSetFalse || (globalAuthNoSet && ruleSet) { + if !noAllow { // 配置了 allow 列表 + if !contains(config.allow, name) { + log.Warnf("consumer %q is not allowed", name) + return deniedUnauthorizedConsumer() + } + // log.Debug("authenticated case 3") + log.Infof("consumer %q authenticated", name) + return authenticated(name) + } + } + + return types.ActionContinue +} + +func deniedNoBasicAuthData() types.Action { + _ = proxywasm.SendHttpResponse(401, WWWAuthenticateHeader(protectionSpace), + []byte("Request denied by Basic Auth check. No Basic Authentication information found."), -1) + return types.ActionContinue +} + +func deniedInvalidCredentials() types.Action { + _ = proxywasm.SendHttpResponse(401, WWWAuthenticateHeader(protectionSpace), + []byte("Request denied by Basic Auth check. Invalid username and/or password."), -1) + return types.ActionContinue +} + +func deniedUnauthorizedConsumer() types.Action { + _ = proxywasm.SendHttpResponse(403, WWWAuthenticateHeader(protectionSpace), + []byte("Request denied by Basic Auth check. Unauthorized consumer."), -1) + return types.ActionContinue +} + +func authenticated(name string) types.Action { + _ = proxywasm.AddHttpRequestHeader("X-Mse-Consumer", name) + return types.ActionContinue +} + +func WWWAuthenticateHeader(realm string) [][2]string { + return [][2]string{ + {"WWW-Authenticate", fmt.Sprintf("Basic realm=%s", realm)}, + } +} + +func contains(arr []string, item string) bool { + for _, i := range arr { + if i == item { + return true + } + } + return false +} diff --git a/plugins/wasm-go/pkg/matcher/rule_matcher.go b/plugins/wasm-go/pkg/matcher/rule_matcher.go index 7937e81f3..f1b6c5cf0 100644 --- a/plugins/wasm-go/pkg/matcher/rule_matcher.go +++ b/plugins/wasm-go/pkg/matcher/rule_matcher.go @@ -90,7 +90,8 @@ func (m RuleMatcher[PluginConfig]) GetMatchConfig() (*PluginConfig, error) { } func (m *RuleMatcher[PluginConfig]) ParseRuleConfig(config gjson.Result, - parsePluginConfig func(gjson.Result, *PluginConfig) error) error { + parsePluginConfig func(gjson.Result, *PluginConfig) error, + parseOverrideConfig func(gjson.Result, PluginConfig, *PluginConfig) error) error { var rules []gjson.Result obj := config.Map() keyCount := len(obj) @@ -122,8 +123,15 @@ func (m *RuleMatcher[PluginConfig]) ParseRuleConfig(config gjson.Result, return fmt.Errorf("parse config failed, no valid rules; global config parse error:%v", globalConfigError) } for _, ruleJson := range rules { - var rule RuleConfig[PluginConfig] - err := parsePluginConfig(ruleJson, &rule.config) + var ( + rule RuleConfig[PluginConfig] + err error + ) + if parseOverrideConfig != nil { + err = parseOverrideConfig(ruleJson, m.globalConfig, &rule.config) + } else { + err = parsePluginConfig(ruleJson, &rule.config) + } if err != nil { return err } diff --git a/plugins/wasm-go/pkg/matcher/rule_matcher_test.go b/plugins/wasm-go/pkg/matcher/rule_matcher_test.go index 97db4a958..7d8e70151 100644 --- a/plugins/wasm-go/pkg/matcher/rule_matcher_test.go +++ b/plugins/wasm-go/pkg/matcher/rule_matcher_test.go @@ -15,6 +15,7 @@ package matcher import ( + "errors" "testing" "github.com/stretchr/testify/assert" @@ -221,7 +222,7 @@ func TestParseRuleConfig(t *testing.T) { for _, c := range cases { t.Run(c.name, func(t *testing.T) { var actual RuleMatcher[customConfig] - err := actual.ParseRuleConfig(gjson.Parse(c.config), parseConfig) + err := actual.ParseRuleConfig(gjson.Parse(c.config), parseConfig, nil) if err != nil { if c.errMsg == "" { t.Errorf("parse failed: %v", err) @@ -236,3 +237,96 @@ func TestParseRuleConfig(t *testing.T) { }) } } + +type completeConfig struct { + // global config + consumers []string + // rule config + allow []string +} + +func parseGlobalConfig(json gjson.Result, global *completeConfig) error { + if json.Get("consumers").Exists() && json.Get("allow").Exists() { + return errors.New("consumers and allow should not be configured at the same level") + } + + for _, item := range json.Get("consumers").Array() { + global.consumers = append(global.consumers, item.String()) + } + + return nil +} + +func parseOverrideRuleConfig(json gjson.Result, global completeConfig, config *completeConfig) error { + if json.Get("consumers").Exists() && json.Get("allow").Exists() { + return errors.New("consumers and allow should not be configured at the same level") + } + + // override config via global + *config = global + + for _, item := range json.Get("allow").Array() { + config.allow = append(config.allow, item.String()) + } + + return nil +} + +func TestParseOverrideConfig(t *testing.T) { + cases := []struct { + name string + config string + errMsg string + expected RuleMatcher[completeConfig] + }{ + { + name: "override rule config", + config: `{"consumers":["c1","c2","c3"],"_rules_":[{"_match_route_":["r1","r2"],"allow":["c1","c3"]}]}`, + expected: RuleMatcher[completeConfig]{ + ruleConfig: []RuleConfig[completeConfig]{ + { + category: Route, + routes: map[string]struct{}{ + "r1": {}, + "r2": {}, + }, + config: completeConfig{ + consumers: []string{"c1", "c2", "c3"}, + allow: []string{"c1", "c3"}, + }, + }, + }, + globalConfig: completeConfig{ + consumers: []string{"c1", "c2", "c3"}, + }, + hasGlobalConfig: true, + }, + }, + { + name: "invalid config", + config: `{"consumers":["c1","c2","c3"],"allow":["c1"]}`, + errMsg: "parse config failed, no valid rules; global config parse error:consumers and allow should not be configured at the same level", + }, + { + name: "invalid config", + config: `{"_rules_":[{"_match_route_":["r1","r2"],"consumers":["c1","c2"],"allow":["c1"]}]}`, + errMsg: "consumers and allow should not be configured at the same level", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + var actual RuleMatcher[completeConfig] + err := actual.ParseRuleConfig(gjson.Parse(c.config), parseGlobalConfig, parseOverrideRuleConfig) + if err != nil { + if c.errMsg == "" { + t.Errorf("parse failed: %v", err) + } + if err.Error() != c.errMsg { + t.Errorf("expect err: %s, actual err: %s", c.errMsg, err.Error()) + } + return + } + assert.Equal(t, c.expected, actual) + }) + } +} diff --git a/plugins/wasm-go/pkg/wrapper/plugin_wrapper.go b/plugins/wasm-go/pkg/wrapper/plugin_wrapper.go index b33b4169a..8ea66c26b 100644 --- a/plugins/wasm-go/pkg/wrapper/plugin_wrapper.go +++ b/plugins/wasm-go/pkg/wrapper/plugin_wrapper.go @@ -44,6 +44,7 @@ type HttpContext interface { } type ParseConfigFunc[PluginConfig any] func(json gjson.Result, config *PluginConfig, log Log) error +type ParseRuleConfigFunc[PluginConfig any] func(json gjson.Result, global PluginConfig, config *PluginConfig, log Log) error type onHttpHeadersFunc[PluginConfig any] func(context HttpContext, config PluginConfig, log Log) types.Action type onHttpBodyFunc[PluginConfig any] func(context HttpContext, config PluginConfig, body []byte, log Log) types.Action type onHttpStreamDoneFunc[PluginConfig any] func(context HttpContext, config PluginConfig, log Log) @@ -54,6 +55,7 @@ type CommonVmCtx[PluginConfig any] struct { log Log hasCustomConfig bool parseConfig ParseConfigFunc[PluginConfig] + parseRuleConfig ParseRuleConfigFunc[PluginConfig] onHttpRequestHeaders onHttpHeadersFunc[PluginConfig] onHttpRequestBody onHttpBodyFunc[PluginConfig] onHttpResponseHeaders onHttpHeadersFunc[PluginConfig] @@ -73,6 +75,13 @@ func ParseConfigBy[PluginConfig any](f ParseConfigFunc[PluginConfig]) SetPluginF } } +func ParseOverrideConfigBy[PluginConfig any](f ParseConfigFunc[PluginConfig], g ParseRuleConfigFunc[PluginConfig]) SetPluginFunc[PluginConfig] { + return func(ctx *CommonVmCtx[PluginConfig]) { + ctx.parseConfig = f + ctx.parseRuleConfig = g + } +} + func ProcessRequestHeadersBy[PluginConfig any](f onHttpHeadersFunc[PluginConfig]) SetPluginFunc[PluginConfig] { return func(ctx *CommonVmCtx[PluginConfig]) { ctx.onHttpRequestHeaders = f @@ -161,9 +170,19 @@ func (ctx *CommonPluginCtx[PluginConfig]) OnPluginStart(int) types.OnPluginStart } jsonData = gjson.ParseBytes(data) } - err = ctx.ParseRuleConfig(jsonData, func(js gjson.Result, cfg *PluginConfig) error { - return ctx.vm.parseConfig(js, cfg, ctx.vm.log) - }) + + var parseOverrideConfig func(gjson.Result, PluginConfig, *PluginConfig) error + if ctx.vm.parseRuleConfig != nil { + parseOverrideConfig = func(js gjson.Result, global PluginConfig, cfg *PluginConfig) error { + return ctx.vm.parseRuleConfig(js, global, cfg, ctx.vm.log) + } + } + err = ctx.ParseRuleConfig(jsonData, + func(js gjson.Result, cfg *PluginConfig) error { + return ctx.vm.parseConfig(js, cfg, ctx.vm.log) + }, + parseOverrideConfig, + ) if err != nil { ctx.vm.log.Warnf("parse rule config failed: %v", err) return types.OnPluginStartStatusFailed diff --git a/test/e2e/conformance/tests/basic-auth.go b/test/e2e/conformance/tests/basic-auth.go new file mode 100644 index 000000000..7ee86d097 --- /dev/null +++ b/test/e2e/conformance/tests/basic-auth.go @@ -0,0 +1,132 @@ +// Copyright (c) 2022 Alibaba Group Holding Ltd. +// +// 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 tests + +import ( + "testing" + + "github.com/alibaba/higress/test/e2e/conformance/utils/http" + "github.com/alibaba/higress/test/e2e/conformance/utils/suite" +) + +func init() { + HigressConformanceTests = append(HigressConformanceTests, WasmPluginsBasicAuth) +} + +var WasmPluginsBasicAuth = suite.ConformanceTest{ + ShortName: "WasmPluginsBasicAuth", + Description: "The Ingress in the higress-conformance-infra namespace test the basic-auth WASM plugin.", + Manifests: []string{"tests/basic-auth.yaml"}, + Test: func(t *testing.T, suite *suite.ConformanceTestSuite) { + testcases := []http.Assertion{ + { + Meta: http.AssertionMeta{ + TestCaseName: "case 1: Successful authentication", + TargetBackend: "infra-backend-v1", + TargetNamespace: "higress-conformance-infra", + }, + Request: http.AssertionRequest{ + ActualRequest: http.Request{ + Host: "foo.com", + Path: "/foo", + Headers: map[string]string{"Authorization": "Basic YWRtaW46MTIzNDU2"}, // base64("admin:123456") + }, + ExpectedRequest: &http.ExpectedRequest{ + Request: http.Request{ + Host: "foo.com", + Path: "/foo", + Headers: map[string]string{"X-Mse-Consumer": "consumer1"}, + }, + }, + }, + Response: http.AssertionResponse{ + ExpectedResponse: http.Response{ + StatusCode: 200, + }, + }, + }, + { + Meta: http.AssertionMeta{ + TestCaseName: "case 2: No Basic Authentication information found", + TargetBackend: "infra-backend-v1", + TargetNamespace: "higress-conformance-infra", + }, + Request: http.AssertionRequest{ + ActualRequest: http.Request{ + Host: "foo.com", + Path: "/foo", + }, + }, + Response: http.AssertionResponse{ + ExpectedResponse: http.Response{ + StatusCode: 401, + }, + AdditionalResponseHeaders: map[string]string{ + "WWW-Authenticate": "Basic realm=MSE Gateway", + }, + }, + }, + { + Meta: http.AssertionMeta{ + TestCaseName: "case 3: Invalid username and/or password", + TargetBackend: "infra-backend-v1", + TargetNamespace: "higress-conformance-infra", + }, + Request: http.AssertionRequest{ + ActualRequest: http.Request{ + Host: "foo.com", + Path: "/foo", + Headers: map[string]string{"Authorization": "Basic YWRtaW46cXdlcg=="}, // base64("admin:qwer") + }, + }, + Response: http.AssertionResponse{ + ExpectedResponse: http.Response{ + StatusCode: 401, + }, + AdditionalResponseHeaders: map[string]string{ + "WWW-Authenticate": "Basic realm=MSE Gateway", + }, + }, + }, + { + Meta: http.AssertionMeta{ + TestCaseName: "case 4: Unauthorized consumer", + TargetBackend: "infra-backend-v1", + TargetNamespace: "higress-conformance-infra", + }, + Request: http.AssertionRequest{ + ActualRequest: http.Request{ + Host: "foo.com", + Path: "/foo", + Headers: map[string]string{"Authorization": "Basic Z3Vlc3Q6YWJj"}, // base64("guest:abc") + }, + }, + Response: http.AssertionResponse{ + ExpectedResponse: http.Response{ + StatusCode: 403, + }, + AdditionalResponseHeaders: map[string]string{ + "WWW-Authenticate": "Basic realm=MSE Gateway", + }, + }, + }, + } + t.Run("WasmPlugins basic-auth", func(t *testing.T) { + for _, testcase := range testcases { + http.MakeRequestAndExpectEventuallyConsistentResponse(t, suite.RoundTripper, suite.TimeoutConfig, suite.GatewayAddress, testcase) + } + }) + }, +} diff --git a/test/e2e/conformance/tests/basic-auth.yaml b/test/e2e/conformance/tests/basic-auth.yaml new file mode 100644 index 000000000..4b8087dab --- /dev/null +++ b/test/e2e/conformance/tests/basic-auth.yaml @@ -0,0 +1,56 @@ +# Copyright (c) 2022 Alibaba Group Holding Ltd. +# +# 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. + +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + name: wasmplugin-basic-auth + namespace: higress-conformance-infra +spec: + ingressClassName: higress + rules: + - host: "foo.com" + http: + paths: + - pathType: Prefix + path: "/foo" + backend: + service: + name: infra-backend-v1 + port: + number: 8080 +--- +apiVersion: extensions.higress.io/v1alpha1 +kind: WasmPlugin +metadata: + name: basic-auth + namespace: higress-system +spec: + defaultConfig: + consumers: + - credential: admin:123456 + name: consumer1 + - credential: guest:abc + name: consumer2 + global_auth: false + defaultConfigDisable: false + matchRules: + - config: + allow: + - consumer1 + configDisable: false + ingress: + - higress-conformance-infra/wasmplugin-basic-auth + url: file:///opt/plugins/wasm-go/extensions/basic-auth/plugin.wasm \ No newline at end of file diff --git a/test/e2e/conformance/tests/cpp-basic-auth.go b/test/e2e/conformance/tests/cpp-basic-auth.go new file mode 100644 index 000000000..c7dc44490 --- /dev/null +++ b/test/e2e/conformance/tests/cpp-basic-auth.go @@ -0,0 +1,132 @@ +// Copyright (c) 2022 Alibaba Group Holding Ltd. +// +// 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 tests + +import ( + "testing" + + "github.com/alibaba/higress/test/e2e/conformance/utils/http" + "github.com/alibaba/higress/test/e2e/conformance/utils/suite" +) + +func init() { + HigressConformanceTests = append(HigressConformanceTests, CPPWasmPluginsBasicAuth) +} + +var CPPWasmPluginsBasicAuth = suite.ConformanceTest{ + ShortName: "CPPWasmPluginsBasicAuth", + Description: "The Ingress in the higress-conformance-infra namespace test the CPP basic-auth WASM plugin.", + Manifests: []string{"tests/cpp-basic-auth.yaml"}, + Test: func(t *testing.T, suite *suite.ConformanceTestSuite) { + testcases := []http.Assertion{ + { + Meta: http.AssertionMeta{ + TestCaseName: "case 1: Successful authentication", + TargetBackend: "infra-backend-v1", + TargetNamespace: "higress-conformance-infra", + }, + Request: http.AssertionRequest{ + ActualRequest: http.Request{ + Host: "foo.com", + Path: "/foo", + Headers: map[string]string{"Authorization": "Basic YWRtaW46MTIzNDU2"}, // base64("admin:123456") + }, + ExpectedRequest: &http.ExpectedRequest{ + Request: http.Request{ + Host: "foo.com", + Path: "/foo", + Headers: map[string]string{"X-Mse-Consumer": "consumer1"}, + }, + }, + }, + Response: http.AssertionResponse{ + ExpectedResponse: http.Response{ + StatusCode: 200, + }, + }, + }, + { + Meta: http.AssertionMeta{ + TestCaseName: "case 2: No Basic Authentication information found", + TargetBackend: "infra-backend-v1", + TargetNamespace: "higress-conformance-infra", + }, + Request: http.AssertionRequest{ + ActualRequest: http.Request{ + Host: "foo.com", + Path: "/foo", + }, + }, + Response: http.AssertionResponse{ + ExpectedResponse: http.Response{ + StatusCode: 401, + }, + AdditionalResponseHeaders: map[string]string{ + "WWW-Authenticate": "Basic realm=MSE Gateway", + }, + }, + }, + { + Meta: http.AssertionMeta{ + TestCaseName: "case 3: Invalid username and/or password", + TargetBackend: "infra-backend-v1", + TargetNamespace: "higress-conformance-infra", + }, + Request: http.AssertionRequest{ + ActualRequest: http.Request{ + Host: "foo.com", + Path: "/foo", + Headers: map[string]string{"Authorization": "Basic YWRtaW46cXdlcg=="}, // base64("admin:qwer") + }, + }, + Response: http.AssertionResponse{ + ExpectedResponse: http.Response{ + StatusCode: 401, + }, + AdditionalResponseHeaders: map[string]string{ + "WWW-Authenticate": "Basic realm=MSE Gateway", + }, + }, + }, + { + Meta: http.AssertionMeta{ + TestCaseName: "case 4: Unauthorized consumer", + TargetBackend: "infra-backend-v1", + TargetNamespace: "higress-conformance-infra", + }, + Request: http.AssertionRequest{ + ActualRequest: http.Request{ + Host: "foo.com", + Path: "/foo", + Headers: map[string]string{"Authorization": "Basic Z3Vlc3Q6YWJj"}, // base64("guest:abc") + }, + }, + Response: http.AssertionResponse{ + ExpectedResponse: http.Response{ + StatusCode: 403, + }, + AdditionalResponseHeaders: map[string]string{ + "WWW-Authenticate": "Basic realm=MSE Gateway", + }, + }, + }, + } + t.Run("WasmPlugins CPP basic-auth", func(t *testing.T) { + for _, testcase := range testcases { + http.MakeRequestAndExpectEventuallyConsistentResponse(t, suite.RoundTripper, suite.TimeoutConfig, suite.GatewayAddress, testcase) + } + }) + }, +} diff --git a/test/e2e/conformance/tests/cpp-basic-auth.yaml b/test/e2e/conformance/tests/cpp-basic-auth.yaml new file mode 100644 index 000000000..8c53830a6 --- /dev/null +++ b/test/e2e/conformance/tests/cpp-basic-auth.yaml @@ -0,0 +1,56 @@ +# Copyright (c) 2022 Alibaba Group Holding Ltd. +# +# 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. + +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + name: wasmplugin-cpp-basic-auth + namespace: higress-conformance-infra +spec: + ingressClassName: higress + rules: + - host: "foo.com" + http: + paths: + - pathType: Prefix + path: "/foo" + backend: + service: + name: infra-backend-v1 + port: + number: 8080 +--- +apiVersion: extensions.higress.io/v1alpha1 +kind: WasmPlugin +metadata: + name: cpp-basic-auth + namespace: higress-system +spec: + defaultConfig: + consumers: + - credential: admin:123456 + name: consumer1 + - credential: guest:abc + name: consumer2 + global_auth: false + defaultConfigDisable: false + matchRules: + - config: + allow: + - consumer1 + configDisable: false + ingress: + - higress-conformance-infra/wasmplugin-cpp-basic-auth + url: file:///opt/plugins/wasm-cpp/extensions/basic-auth/plugin.wasm \ No newline at end of file diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index c0e4a9bef..2dc54757a 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -60,6 +60,7 @@ func TestHigressConformanceTests(t *testing.T) { m := make(map[string]suite.ConformanceTest) m["request_block"] = tests.CPPWasmPluginsRequestBlock m["key_auth"] = tests.CPPWasmPluginsKeyAuth + m["basic_auth"] = tests.CPPWasmPluginsBasicAuth higressTests = []suite.ConformanceTest{ m[*wasmPluginName], @@ -68,6 +69,7 @@ func TestHigressConformanceTests(t *testing.T) { higressTests = []suite.ConformanceTest{ tests.WasmPluginsRequestBlock, tests.WasmPluginsJwtAuth, + tests.WasmPluginsBasicAuth, } } } else {