feat: support ext_auth wasmplugin (#1103)

This commit is contained in:
韩贤涛
2024-07-17 15:30:32 +08:00
committed by GitHub
parent d5a9ff3a98
commit c0f2cafdc8
10 changed files with 954 additions and 0 deletions

View File

@@ -24,6 +24,7 @@ github.com/tidwall/resp v0.1.1 h1:Ly20wkhqKTmDUPlyM1S7pWo5kk0tDu8OoC/vFArXmwE=
github.com/tidwall/resp v0.1.1/go.mod h1:3/FrruOBAxPTPtundW0VXgmsQ4ZBA0Aw714lVYgwFa0=
github.com/wasilibs/go-re2 v1.5.3 h1:wiuTcgDZdLhu8NG8oqF5sF5Q3yIU14lPAvXqeYzDK3g=
github.com/wasilibs/go-re2 v1.5.3/go.mod h1:PzpVPsBdFC7vM8QJbbEnOeTmwA0DGE783d/Gex8eCV8=
github.com/wasilibs/nottinygc v0.4.0 h1:h1TJMihMC4neN6Zq+WKpLxgd9xCFMw7O9ETLwY2exJQ=
github.com/zmap/go-iptree v0.0.0-20210731043055-d4e632617837 h1:DjHnADS2r2zynZ3WkCFAQ+PNYngMSNceRROi0pO6c3M=
github.com/zmap/go-iptree v0.0.0-20210731043055-d4e632617837/go.mod h1:9vp0bxqozzQwcjBwenEXfKVq8+mYbwHkQ1NF9Ap0DMw=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@@ -0,0 +1,162 @@
# 功能说明
`ext-auth` 插件实现了向外部授权服务发送鉴权请求以检查客户端请求是否得到授权。该插件实现时参考了Envoy原生的[ext_authz filter](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/ext_authz_filter)实现了原生filter中对接HTTP服务的部分能力
# 配置字段
| 名称 | 数据类型 | 必填 | 默认值 | 描述 |
| ------------------------------- | -------- | ---- | ------ |------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `http_service` | object | 是 | - | 外部授权服务配置 |
| `failure_mode_allow` | bool | 否 | false | 当设置为 true 时,即使与授权服务的通信失败,或者授权服务返回了 HTTP 5xx 错误,仍会接受客户端请求 |
| `failure_mode_allow_header_add` | bool | 否 | false | 当 `failure_mode_allow``failure_mode_allow_header_add` 都设置为 true 时,若与授权服务的通信失败,或授权服务返回了 HTTP 5xx 错误,那么请求头中将会添加 `x-envoy-auth-failure-mode-allowed: true` |
| `status_on_error` | int | 否 | 403 | 当授权服务无法访问或状态码为 5xx 时,设置返回给客户端的 HTTP 状态码。默认状态码是 `403` |
`http_service`中每一项的配置字段说明
| 名称 | 数据类型 | 必填 | 默认值 | 描述 |
| ------------------------ | -------- | ---- | ------ | ------------------------------------- |
| `endpoint` | object | 是 | - | 发送鉴权请求的 HTTP 服务信息 |
| `timeout` | int | 否 | 200 | `ext-auth` 服务连接超时时间,单位毫秒 |
| `authorization_request` | object | 否 | - | 发送鉴权请求配置 |
| `authorization_response` | object | 否 | - | 处理鉴权响应配置 |
`endpoint`中每一项的配置字段说明
| 名称 | 数据类型 | 必填 | 默认值 | 描述 |
| ---------------- | -------- | ---- | ------ | --------------------------------------------------- |
| `service_source` | string | 是 | - | 类型为固定 ip 或者 dns输入授权服务的注册来源 |
| `service_name` | string | 是 | - | 输入授权服务的注册名称 |
| `service_port` | string | 是 | - | 输入授权服务的服务端口 |
| `service_domain` | string | 否 | - | 当类型为dns时必须填写输入 `ext-auth` 服务的domain |
| `request_method` | string | 否 | GET | 客户端向授权服务发送请求的HTTP Method |
| `path` | string | 是 | - | 输入授权服务的请求路径 |
`authorization_request`中每一项的配置字段说明
| 名称 | 数据类型 | 必填 | 默认值 | 描述 |
| ------------------- | ---------------------- | ---- | ------ | ------------------------------------------------------------ |
| `allowed_headers` | array of StringMatcher | 否 | - | 当设置后,具有相应匹配项的客户端请求头将添加到授权服务请求中的请求头中。除了用户自定义的头部匹配规则外,授权服务请求中会自动包含`Host`, `Method`, `Path`, `Content-Length``Authorization`这几个关键的HTTP头 |
| `headers_to_add` | `map[string]string` | 否 | - | 设置将包含在授权服务请求中的请求头列表。请注意,同名的客户端请求头将被覆盖 |
| `with_request_body` | bool | 否 | false | 缓冲客户端请求体并将其发送至鉴权请求中HTTP Method为GET、OPTIONS、HEAD请求时不生效 |
`authorization_response`中每一项的配置字段说明
| 名称 | 数据类型 | 必填 | 默认值 | 描述 |
| -------------------------- | ---------------------- | ---- | ------ |---------------------------------------------------------------------------------|
| `allowed_upstream_headers` | array of StringMatcher | 否 | - | 当设置后,具有相应匹配项的鉴权请求的响应头将添加到原始的客户端请求头中。请注意,同名的请求头将被覆盖 |
| `allowed_client_headers` | array of StringMatcher | 否 | - | 如果不设置,在请求被拒绝时,所有的鉴权请求的响应头将添加到客户端的响应头中。当设置后,在请求被拒绝时,具有相应匹配项的鉴权请求的响应头将添加到客户端的响应头中 |
`StringMatcher`类型每一项的配置字段说明,在使用`array of StringMatcher`时会按照数组中定义的StringMatcher顺序依次进行配置
| 名称 | 数据类型 | 必填 | 默认值 | 描述 |
| ---------- | -------- | ------------------------------------------------------------ | ------ | -------- |
| `exact` | string | 否,`exact` , `prefix` , `suffix`, `contains`, `regex` 中选填一项 | - | 精确匹配 |
| `prefix` | string | 否,`exact` , `prefix` , `suffix`, `contains`, `regex` 中选填一项 | - | 前缀匹配 |
| `suffix` | string | 否,`exact` , `prefix` , `suffix`, `contains`, `regex` 中选填一项 | - | 后缀匹配 |
| `contains` | string | 否,`exact` , `prefix` , `suffix`, `contains`, `regex` 中选填一项 | - | 是否包含 |
| `regex` | string | 否,`exact` , `prefix` , `suffix`, `contains`, `regex` 中选填一项 | - | 正则匹配 |
# 配置示例
下面假设 `ext-auth` 服务在Kubernetes中serviceName为 `ext-auth`,端口 `8090`,路径为 `/auth`,命名空间为 `backend`
## 示例1
`ext-auth` 插件的配置:
```yaml
http_service:
endpoint:
service_name: ext-auth
namespace: backend
service_port: 8090
service_source: k8s
path: /auth
request_method: POST
timeout: 500
```
使用如下请求网关,当开启 `ext-auth` 插件后:
```shell
curl -i http://localhost:8082/users -X GET -H "foo: bar" -H "Authorization: xxx"
```
**请求 `ext-auth` 服务成功:**
`ext-auth` 服务将接收到如下的鉴权请求:
```
POST /auth HTTP/1.1
Host: ext-auth
Authorization: xxx
Content-Length: 0
```
**请求 `ext-auth` 服务失败:**
当调用 `ext-auth` 服务响应为 5xx 时客户端将接收到HTTP响应码403和 `ext-auth` 服务返回的全量响应头
假如 `ext-auth` 服务返回了 `x-auth-version: 1.0``x-auth-failed: true` 的响应头,会传递给客户端
```
HTTP/1.1 403 Forbidden
x-auth-version: 1.0
x-auth-failed: true
date: Tue, 16 Jul 2024 00:19:41 GMT
server: istio-envoy
content-length: 0
```
`ext-auth` 无法访问或状态码为 5xx 时,将以 `status_on_error` 配置的状态码拒绝客户端请求
`ext-auth` 服务返回其他 HTTP 状态码时,将以返回的状态码拒绝客户端请求。如果配置了 `allowed_client_headers`,具有相应匹配项的响应头将添加到客户端的响应中
## 示例2
`ext-auth` 插件的配置:
```yaml
http_service:
authorization_request:
allowed_headers:
- exact: x-auth-version
headers_to_add:
x-envoy-header: true
authorization_response:
allowed_upstream_headers:
- exact: x-user-id
- exact: x-auth-version
endpoint:
service_name: ext-auth
namespace: backend
service_port: 8090
service_source: k8s
path: /auth
request_method: POST
timeout: 500
```
使用如下请求网关,当开启 `ext-auth` 插件后:
```shell
curl -i http://localhost:8082/users -X GET -H "foo: bar" -H "Authorization: xxx" -H "X-Auth-Version: 1.0"
```
`ext-auth` 服务将接收到如下的鉴权请求:
```
POST /auth HTTP/1.1
Host: ext-auth
Authorization: xxx
X-Auth-Version: 1.0
x-envoy-header: true
Content-Length: 0
```
`ext-auth` 服务返回响应头中如果包含 `x-user-id``x-auth-version`网关调用upstream时的请求中会带上这两个请求头

View File

@@ -0,0 +1,229 @@
package main
import (
"errors"
"ext-auth/expr"
"fmt"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"github.com/tidwall/gjson"
"net/http"
"strings"
)
const (
DefaultStatusOnError uint32 = http.StatusForbidden
DefaultHttpServiceTimeout uint32 = 200
)
type ExtAuthConfig struct {
httpService HttpService
failureModeAllow bool
failureModeAllowHeaderAdd bool
statusOnError uint32
}
type HttpService struct {
client wrapper.HttpClient
requestMethod string
path string
timeout uint32
authorizationRequest AuthorizationRequest
authorizationResponse AuthorizationResponse
}
type AuthorizationRequest struct {
// allowedHeaders In addition to the users supplied matchers,
// Host, Method, Path, Content-Length, and Authorization are automatically included to the list.
allowedHeaders expr.Matcher
headersToAdd map[string]string
withRequestBody bool
}
type AuthorizationResponse struct {
allowedUpstreamHeaders expr.Matcher
allowedClientHeaders expr.Matcher
}
func parseConfig(json gjson.Result, config *ExtAuthConfig, log wrapper.Log) error {
httpServiceConfig := json.Get("http_service")
if !httpServiceConfig.Exists() {
return errors.New("missing http_service in config")
}
err := parseHttpServiceConfig(httpServiceConfig, config)
if err != nil {
return err
}
failureModeAllow := json.Get("failure_mode_allow")
if failureModeAllow.Exists() {
config.failureModeAllow = failureModeAllow.Bool()
}
failureModeAllowHeaderAdd := json.Get("failure_mode_allow_header_add")
if failureModeAllowHeaderAdd.Exists() {
config.failureModeAllowHeaderAdd = failureModeAllowHeaderAdd.Bool()
}
statusOnError := json.Get("status_on_error")
if statusOnError.Exists() {
config.statusOnError = uint32(statusOnError.Uint())
} else {
config.statusOnError = DefaultStatusOnError
}
return nil
}
func parseHttpServiceConfig(json gjson.Result, config *ExtAuthConfig) error {
var httpService HttpService
if err := parseEndpointConfig(json, &httpService); err != nil {
return err
}
timeout := uint32(json.Get("timeout").Uint())
if timeout == 0 {
timeout = DefaultHttpServiceTimeout
}
httpService.timeout = timeout
if err := parseAuthorizationRequestConfig(json, &httpService); err != nil {
return err
}
if err := parseAuthorizationResponseConfig(json, &httpService); err != nil {
return err
}
config.httpService = httpService
return nil
}
func parseEndpointConfig(json gjson.Result, httpService *HttpService) error {
endpointConfig := json.Get("endpoint")
if !endpointConfig.Exists() {
return errors.New("missing endpoint in config")
}
serviceSource := endpointConfig.Get("service_source").String()
serviceName := endpointConfig.Get("service_name").String()
servicePort := endpointConfig.Get("service_port").Int()
if serviceName == "" || servicePort == 0 {
return errors.New("invalid service config")
}
switch serviceSource {
case "k8s":
namespace := json.Get("namespace").String()
httpService.client = wrapper.NewClusterClient(wrapper.K8sCluster{
ServiceName: serviceName,
Namespace: namespace,
Port: servicePort,
})
return nil
case "nacos":
namespace := json.Get("namespace").String()
httpService.client = wrapper.NewClusterClient(wrapper.NacosCluster{
ServiceName: serviceName,
NamespaceID: namespace,
Port: servicePort,
})
return nil
case "ip":
httpService.client = wrapper.NewClusterClient(wrapper.StaticIpCluster{
ServiceName: serviceName,
Port: servicePort,
})
case "dns":
domain := endpointConfig.Get("domain").String()
httpService.client = wrapper.NewClusterClient(wrapper.DnsCluster{
ServiceName: serviceName,
Port: servicePort,
Domain: domain,
})
default:
return errors.New("unknown service source: " + serviceSource)
}
requestMethodConfig := endpointConfig.Get("request_method")
if !requestMethodConfig.Exists() {
httpService.requestMethod = http.MethodGet
} else {
httpService.requestMethod = strings.ToUpper(requestMethodConfig.String())
}
pathConfig := endpointConfig.Get("path")
if !pathConfig.Exists() {
return errors.New("missing path in config")
}
httpService.path = pathConfig.String()
return nil
}
func parseAuthorizationRequestConfig(json gjson.Result, httpService *HttpService) error {
authorizationRequestConfig := json.Get("authorization_request")
if authorizationRequestConfig.Exists() {
var authorizationRequest AuthorizationRequest
headersToAdd := map[string]string{}
headersToAddConfig := authorizationRequestConfig.Get("headers_to_add")
if headersToAddConfig.Exists() {
for key, value := range headersToAddConfig.Map() {
headersToAdd[key] = value.Str
}
}
authorizationRequest.headersToAdd = headersToAdd
withRequestBody := authorizationRequestConfig.Get("with_request_body")
if withRequestBody.Exists() {
// withRequestBody is true and the request method is GET, OPTIONS or HEAD
if withRequestBody.Bool() &&
(httpService.requestMethod == http.MethodGet || httpService.requestMethod == http.MethodOptions || httpService.requestMethod == http.MethodHead) {
return errors.New(fmt.Sprintf("requestMethod %s does not support with_request_body set to true", httpService.requestMethod))
}
authorizationRequest.withRequestBody = withRequestBody.Bool()
}
allowedHeaders := authorizationRequestConfig.Get("allowed_headers")
if allowedHeaders.Exists() {
result, err := expr.BuildRepeatedStringMatcherIgnoreCase(allowedHeaders.Array())
if err != nil {
return err
}
authorizationRequest.allowedHeaders = result
}
httpService.authorizationRequest = authorizationRequest
}
return nil
}
func parseAuthorizationResponseConfig(json gjson.Result, httpService *HttpService) error {
authorizationResponseConfig := json.Get("authorization_response")
if authorizationResponseConfig.Exists() {
var authorizationResponse AuthorizationResponse
allowedUpstreamHeaders := authorizationResponseConfig.Get("allowed_upstream_headers")
if allowedUpstreamHeaders.Exists() {
result, err := expr.BuildRepeatedStringMatcherIgnoreCase(allowedUpstreamHeaders.Array())
if err != nil {
return err
}
authorizationResponse.allowedUpstreamHeaders = result
}
allowedClientHeaders := authorizationResponseConfig.Get("allowed_client_headers")
if allowedClientHeaders.Exists() {
result, err := expr.BuildRepeatedStringMatcherIgnoreCase(allowedClientHeaders.Array())
if err != nil {
return err
}
authorizationResponse.allowedClientHeaders = result
}
httpService.authorizationResponse = authorizationResponse
}
return nil
}

View File

@@ -0,0 +1,201 @@
package expr
import (
"errors"
"github.com/tidwall/gjson"
"regexp"
"strings"
)
const (
matchPatternExact string = "exact"
matchPatternPrefix string = "prefix"
matchPatternSuffix string = "suffix"
matchPatternContains string = "contains"
matchPatternRegex string = "regex"
matchIgnoreCase string = "ignore_case"
)
type Matcher interface {
Match(s string) bool
}
type stringExactMatcher struct {
target string
ignoreCase bool
}
func (m *stringExactMatcher) Match(s string) bool {
if m.ignoreCase {
return strings.ToLower(s) == m.target
}
return s == m.target
}
type stringPrefixMatcher struct {
target string
ignoreCase bool
}
func (m *stringPrefixMatcher) Match(s string) bool {
if m.ignoreCase {
return strings.HasPrefix(strings.ToLower(s), m.target)
}
return strings.HasPrefix(s, m.target)
}
type stringSuffixMatcher struct {
target string
ignoreCase bool
}
func (m *stringSuffixMatcher) Match(s string) bool {
if m.ignoreCase {
return strings.HasSuffix(strings.ToLower(s), m.target)
}
return strings.HasSuffix(s, m.target)
}
type stringContainsMatcher struct {
target string
ignoreCase bool
}
func (m *stringContainsMatcher) Match(s string) bool {
if m.ignoreCase {
return strings.Contains(strings.ToLower(s), m.target)
}
return strings.Contains(s, m.target)
}
type stringRegexMatcher struct {
regex *regexp.Regexp
}
func (m *stringRegexMatcher) Match(s string) bool {
return m.regex.MatchString(s)
}
type repeatedStringMatcher struct {
matchers []Matcher
}
func (rsm *repeatedStringMatcher) Match(s string) bool {
for _, m := range rsm.matchers {
if m.Match(s) {
return true
}
}
return false
}
func buildRepeatedStringMatcher(matchers []gjson.Result, allIgnoreCase bool) (Matcher, error) {
builtMatchers := make([]Matcher, len(matchers))
createMatcher := func(json gjson.Result, targetKey string, ignoreCase bool, matcherType MatcherConstructor) (Matcher, error) {
result := json.Get(targetKey)
if result.Exists() && result.String() != "" {
target := result.String()
return matcherType(target, ignoreCase)
}
return nil, nil
}
for i, item := range matchers {
var matcher Matcher
var err error
// If allIgnoreCase is true, it takes precedence over any user configuration,
// forcing case-insensitive matching regardless of individual item settings.
ignoreCase := allIgnoreCase
if !allIgnoreCase {
ignoreCaseResult := item.Get(matchIgnoreCase)
if ignoreCaseResult.Exists() && ignoreCaseResult.Bool() {
ignoreCase = true
}
}
for _, matcherType := range []struct {
key string
creator MatcherConstructor
}{
{matchPatternExact, newStringExactMatcher},
{matchPatternPrefix, newStringPrefixMatcher},
{matchPatternSuffix, newStringSuffixMatcher},
{matchPatternContains, newStringContainsMatcher},
{matchPatternRegex, newStringRegexMatcher},
} {
if matcher, err = createMatcher(item, matcherType.key, ignoreCase, matcherType.creator); err != nil {
return nil, err
}
if matcher != nil {
break
}
}
if matcher == nil {
return nil, errors.New("unknown string matcher type")
}
builtMatchers[i] = matcher
}
return &repeatedStringMatcher{
matchers: builtMatchers,
}, nil
}
type MatcherConstructor func(string, bool) (Matcher, error)
func newStringExactMatcher(target string, ignoreCase bool) (Matcher, error) {
if ignoreCase {
target = strings.ToLower(target)
}
return &stringExactMatcher{target: target, ignoreCase: ignoreCase}, nil
}
func newStringPrefixMatcher(target string, ignoreCase bool) (Matcher, error) {
if ignoreCase {
target = strings.ToLower(target)
}
return &stringPrefixMatcher{target: target, ignoreCase: ignoreCase}, nil
}
func newStringSuffixMatcher(target string, ignoreCase bool) (Matcher, error) {
if ignoreCase {
target = strings.ToLower(target)
}
return &stringSuffixMatcher{target: target, ignoreCase: ignoreCase}, nil
}
func newStringContainsMatcher(target string, ignoreCase bool) (Matcher, error) {
if ignoreCase {
target = strings.ToLower(target)
}
return &stringContainsMatcher{target: target, ignoreCase: ignoreCase}, nil
}
func newStringRegexMatcher(target string, ignoreCase bool) (Matcher, error) {
if ignoreCase && !strings.HasPrefix(target, "(?i)") {
target = "(?i)" + target
}
re, err := regexp.Compile(target)
if err != nil {
return nil, err
}
return &stringRegexMatcher{regex: re}, nil
}
func BuildRepeatedStringMatcherIgnoreCase(matchers []gjson.Result) (Matcher, error) {
return buildRepeatedStringMatcher(matchers, true)
}
func BuildRepeatedStringMatcher(matchers []gjson.Result) (Matcher, error) {
return buildRepeatedStringMatcher(matchers, false)
}
func BuildStringMatcher(matcher gjson.Result) (Matcher, error) {
return BuildRepeatedStringMatcher([]gjson.Result{matcher})
}

View File

@@ -0,0 +1,121 @@
package expr
import (
"github.com/stretchr/testify/assert"
"github.com/tidwall/gjson"
"testing"
)
func TestStringMatcher(t *testing.T) {
tests := []struct {
name string
cfg string
matched []string
mismatched []string
}{
{
name: "exact",
cfg: `{"exact": "foo"}`,
matched: []string{"foo"},
mismatched: []string{"fo", "fooo"},
},
{
name: "exact, ignore_case",
cfg: `{"exact": "foo", "ignore_case": true}`,
matched: []string{"Foo", "foo"},
},
{
name: "prefix",
cfg: `{"prefix": "/p"}`,
matched: []string{"/p", "/pa"},
mismatched: []string{"/P"},
},
{
name: "prefix, ignore_case",
cfg: `{"prefix": "/p", "ignore_case": true}`,
matched: []string{"/P", "/p", "/pa", "/Pa"},
mismatched: []string{"/"},
},
{
name: "suffix",
cfg: `{"suffix": "foo"}`,
matched: []string{"foo", "0foo"},
mismatched: []string{"fo", "fooo", "aFoo"},
},
{
name: "suffix, ignore_case",
cfg: `{"suffix": "foo", "ignore_case": true}`,
matched: []string{"aFoo", "foo"},
mismatched: []string{"fo", "fooo"},
},
{
name: "contains",
cfg: `{"contains": "foo"}`,
matched: []string{"foo", "0foo", "fooo"},
mismatched: []string{"fo", "aFoo"},
},
{
name: "contains, ignore_case",
cfg: `{"contains": "foo", "ignore_case": true}`,
matched: []string{"aFoo", "foo", "FoO"},
mismatched: []string{"fo"},
},
{
name: "regex",
cfg: `{"regex": "fo{2}"}`,
matched: []string{"foo", "0foo", "fooo"},
mismatched: []string{"aFoo", "fo"},
},
{
name: "regex, ignore_case",
cfg: `{"regex": "fo{2}", "ignore_case": true}`,
matched: []string{"foo", "0foo", "fooo", "aFoo"},
mismatched: []string{"fo"},
},
{
name: "regex, ignore_case & case insensitive specified in regex",
cfg: `{"regex": "(?i)fo{2}", "ignore_case": true}`,
matched: []string{"foo", "0foo", "fooo", "aFoo"},
mismatched: []string{"fo"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
built, _ := BuildStringMatcher(gjson.Parse(tt.cfg))
for _, s := range tt.matched {
assert.True(t, built.Match(s))
}
for _, s := range tt.mismatched {
assert.False(t, built.Match(s))
}
})
}
}
func TestBuildRepeatedStringMatcherIgnoreCase(t *testing.T) {
cfgs := []string{
`{"exact":"foo"}`,
`{"prefix":"pre"}`,
`{"regex":"^Cache"}`,
}
matched := []string{"Foo", "foO", "foo", "PreA", "cache-control", "Cache-Control"}
mismatched := []string{"afoo", "fo"}
ms := []gjson.Result{}
for _, cfg := range cfgs {
ms = append(ms, gjson.Parse(cfg))
}
built, _ := BuildRepeatedStringMatcherIgnoreCase(ms)
for _, s := range matched {
assert.True(t, built.Match(s))
}
for _, s := range mismatched {
assert.False(t, built.Match(s))
}
}
func TestPassOutRegexCompileErr(t *testing.T) {
cfg := `{"regex":"(?!)aa"}`
_, err := BuildRepeatedStringMatcher([]gjson.Result{gjson.Parse(cfg)})
assert.NotNil(t, err)
}

View File

@@ -0,0 +1,24 @@
module ext-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/higress-group/proxy-wasm-go-sdk v0.0.0-20240711023527-ba358c48772f
github.com/stretchr/testify v1.8.4
github.com/tidwall/gjson v1.14.3
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/higress-group/nottinygc v0.0.0-20231101025119-e93c4c2f8520 // indirect
github.com/magefile/mage v1.14.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tidwall/resp v0.1.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@@ -0,0 +1,26 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/higress-group/nottinygc v0.0.0-20231101025119-e93c4c2f8520 h1:IHDghbGQ2DTIXHBHxWfqCYQW1fKjyJ/I7W1pMyUDeEA=
github.com/higress-group/nottinygc v0.0.0-20231101025119-e93c4c2f8520/go.mod h1:Nz8ORLaFiLWotg6GeKlJMhv8cci8mM43uEnLA5t8iew=
github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240711023527-ba358c48772f h1:ZIiIBRvIw62gA5MJhuwp1+2wWbqL9IGElQ499rUsYYg=
github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240711023527-ba358c48772f/go.mod h1:hNFjhrLUIq+kJ9bOcs8QtiplSQ61GZXtd2xHKx4BYRo=
github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo=
github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/tidwall/gjson v1.14.3 h1:9jvXn7olKEHU1S9vwoMGliaT8jq1vJ7IH/n9zD9Dnlw=
github.com/tidwall/gjson v1.14.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/resp v0.1.1 h1:Ly20wkhqKTmDUPlyM1S7pWo5kk0tDu8OoC/vFArXmwE=
github.com/tidwall/resp v0.1.1/go.mod h1:3/FrruOBAxPTPtundW0VXgmsQ4ZBA0Aw714lVYgwFa0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,147 @@
// Copyright (c) 2024 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 main
import (
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types"
"net/http"
"strconv"
)
func main() {
wrapper.SetCtx(
"ext-auth",
wrapper.ParseConfigBy(parseConfig),
wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders),
wrapper.ProcessRequestBodyBy(onHttpRequestBody),
)
}
const (
HeaderContentLength string = "content-length"
HeaderAuthorization string = "authorization"
HeaderFailureModeAllow string = "x-envoy-auth-failure-mode-allowed"
)
func onHttpRequestHeaders(ctx wrapper.HttpContext, config ExtAuthConfig, log wrapper.Log) types.Action {
contentLengthStr, _ := proxywasm.GetHttpRequestHeader(HeaderContentLength)
hasRequestBody := false
if contentLengthStr != "" {
contentLength, err := strconv.Atoi(contentLengthStr)
hasRequestBody = err == nil && contentLength > 0
}
// If withRequestBody is true AND the HTTP request contains a request body,
// it will be handled in the onHttpRequestBody phase.
if config.httpService.authorizationRequest.withRequestBody && hasRequestBody {
// Disable the route re-calculation since the plugin may modify some headers related to the chosen route.
ctx.DisableReroute()
// The request has a body and requires delaying the header transmission until a cache miss occurs,
// at which point the header should be sent.
return types.HeaderStopIteration
}
ctx.DontReadRequestBody()
return checkExtAuth(ctx, config, nil, log)
}
func onHttpRequestBody(ctx wrapper.HttpContext, config ExtAuthConfig, body []byte, log wrapper.Log) types.Action {
if config.httpService.authorizationRequest.withRequestBody {
return checkExtAuth(ctx, config, body, log)
}
return types.ActionContinue
}
func checkExtAuth(ctx wrapper.HttpContext, config ExtAuthConfig, body []byte, log wrapper.Log) types.Action {
// build extAuth request headers
extAuthReqHeaders := http.Header{}
httpServiceConfig := config.httpService
requestConfig := httpServiceConfig.authorizationRequest
reqHeaders, _ := proxywasm.GetHttpRequestHeaders()
if requestConfig.allowedHeaders != nil {
for _, header := range reqHeaders {
headK := header[0]
if requestConfig.allowedHeaders.Match(headK) {
extAuthReqHeaders.Set(headK, header[1])
}
}
}
for key, value := range requestConfig.headersToAdd {
extAuthReqHeaders.Set(key, value)
}
// add Authorization header
authorization := extractFromHeader(reqHeaders, HeaderAuthorization)
if authorization != "" {
extAuthReqHeaders.Set(HeaderAuthorization, authorization)
}
// call ext auth server
err := httpServiceConfig.client.Post(httpServiceConfig.path, reconvertHeaders(extAuthReqHeaders), body,
func(statusCode int, responseHeaders http.Header, responseBody []byte) {
defer proxywasm.ResumeHttpRequest()
if statusCode != http.StatusOK {
log.Errorf("failed to call ext auth server, status: %d", statusCode)
callExtAuthServerErrorHandler(config, statusCode, responseHeaders)
return
}
if httpServiceConfig.authorizationResponse.allowedUpstreamHeaders != nil {
for headK, headV := range responseHeaders {
if httpServiceConfig.authorizationResponse.allowedUpstreamHeaders.Match(headK) {
_ = proxywasm.ReplaceHttpRequestHeader(headK, headV[0])
}
}
}
}, httpServiceConfig.timeout)
if err != nil {
log.Errorf("failed to call ext auth server: %v", err)
// Since the handling logic for call errors and HTTP status code 500 is the same, we directly use 500 here.
callExtAuthServerErrorHandler(config, http.StatusInternalServerError, nil)
return types.ActionContinue
}
return types.ActionPause
}
func callExtAuthServerErrorHandler(config ExtAuthConfig, statusCode int, extAuthRespHeaders http.Header) {
if statusCode >= http.StatusInternalServerError && config.failureModeAllow {
if config.failureModeAllowHeaderAdd {
_ = proxywasm.ReplaceHttpRequestHeader(HeaderFailureModeAllow, "true")
}
return
}
var respHeaders = extAuthRespHeaders
if config.httpService.authorizationResponse.allowedClientHeaders != nil {
respHeaders = http.Header{}
for headK, headV := range extAuthRespHeaders {
if config.httpService.authorizationResponse.allowedClientHeaders.Match(headK) {
respHeaders.Set(headK, headV[0])
}
}
}
// Rejects client requests with statusOnError on extAuth unavailability or 5xx.
// Otherwise, uses the extAuth's returned status code to reject requests.
statusToUse := statusCode
if statusCode >= http.StatusInternalServerError {
statusToUse = int(config.statusOnError)
}
_ = sendResponse(uint32(statusToUse), "ext-auth.unauthorized", respHeaders)
}

View File

@@ -0,0 +1,38 @@
package main
import (
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
"net/http"
"sort"
"strings"
)
func sendResponse(statusCode uint32, statusCodeDetailData string, headers http.Header) error {
return proxywasm.SendHttpResponseWithDetail(statusCode, statusCodeDetailData, reconvertHeaders(headers), nil, -1)
}
func reconvertHeaders(headers http.Header) [][2]string {
var ret [][2]string
if headers == nil {
return ret
}
for k, vs := range headers {
for _, v := range vs {
ret = append(ret, [2]string{k, v})
}
}
sort.SliceStable(ret, func(i, j int) bool {
return ret[i][0] < ret[j][0]
})
return ret
}
func extractFromHeader(headers [][2]string, headerKey string) string {
for _, header := range headers {
key := header[0]
if strings.ToLower(key) == headerKey {
return strings.TrimSpace(header[1])
}
}
return ""
}

View File

@@ -34,6 +34,7 @@ type HttpClient interface {
Delete(path string, headers [][2]string, body []byte, cb ResponseCallback, timeoutMillisecond ...uint32) error
Connect(path string, headers [][2]string, body []byte, cb ResponseCallback, timeoutMillisecond ...uint32) error
Trace(path string, headers [][2]string, body []byte, cb ResponseCallback, timeoutMillisecond ...uint32) error
Call(method, path string, headers [][2]string, body []byte, cb ResponseCallback, timeoutMillisecond ...uint32) error
}
type ClusterClient[C Cluster] struct {
@@ -72,6 +73,10 @@ func (c ClusterClient[C]) Trace(path string, headers [][2]string, body []byte, c
return HttpCall(c.cluster, http.MethodTrace, path, headers, body, cb, timeoutMillisecond...)
}
func (c ClusterClient[C]) Call(method, path string, headers [][2]string, body []byte, cb ResponseCallback, timeoutMillisecond ...uint32) error {
return HttpCall(c.cluster, method, path, headers, body, cb, timeoutMillisecond...)
}
func HttpCall(cluster Cluster, method, path string, headers [][2]string, body []byte,
callback ResponseCallback, timeoutMillisecond ...uint32) error {
for i := len(headers) - 1; i >= 0; i-- {