From 3c611f33edb0dd8727b6717cf4cbd89b48da7479 Mon Sep 17 00:00:00 2001 From: ifish Date: Mon, 25 Sep 2023 10:44:51 +0800 Subject: [PATCH] feat: add jwe decrypt plugin --- apisix/plugins/jwe-decrypt.lua | 229 ++++++++++++++++++++++++++ conf/config-default.yaml | 1 + docs/en/latest/plugins/jwe-decrypt.md | 191 +++++++++++++++++++++ 3 files changed, 421 insertions(+) create mode 100644 apisix/plugins/jwe-decrypt.lua create mode 100644 docs/en/latest/plugins/jwe-decrypt.md diff --git a/apisix/plugins/jwe-decrypt.lua b/apisix/plugins/jwe-decrypt.lua new file mode 100644 index 0000000000000..eaddaf65cdf44 --- /dev/null +++ b/apisix/plugins/jwe-decrypt.lua @@ -0,0 +1,229 @@ +-- +-- Licensed to the Apache Software Foundation (ASF) under one or more +-- contributor license agreements. See the NOTICE file distributed with +-- this work for additional information regarding copyright ownership. +-- The ASF licenses this file to You 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. +-- +local core = require("apisix.core") +local consumer_mod = require("apisix.consumer") +local base64 = require("ngx.base64") +local ngx = ngx +local ngx_time = ngx.time +local sub_str = string.sub +local table_insert = table.insert +local table_concat = table.concat +local ngx_re_gmatch = ngx.re.gmatch +local plugin_name = "jwe-decrypt" +local pcall = pcall +local cipher = assert(require("resty.openssl.cipher").new("aes-256-gcm")) + +local schema = { + type = "object", + properties = { + header = { + type = "string", + default = "Authorization" + }, + forward_header = { + type = "string", + default = "Authorization" + }, + strict = { + type = "boolean", + default = true + } + }, +} + +local consumer_schema = { + type = "object", + -- can't use additionalProperties with dependencies + properties = { + key = { type = "string" }, + secret = { type = "string" }, + base64_secret = { type = "boolean" }, + }, + required = { "key", "secret" }, +} + + +local _M = { + version = 0.1, + priority = 2510, + type = 'auth', + name = plugin_name, + schema = schema, + consumer_schema = consumer_schema +} + + +function _M.check_schema(conf, schema_type) + core.log.info("input conf: ", core.json.delay_encode(conf)) + + local ok, err + if schema_type == core.schema.TYPE_CONSUMER then + ok, err = core.schema.check(consumer_schema, conf) + else + return core.schema.check(schema, conf) + end + + if not ok then + return false, err + end + + return true +end + +local function get_secret(conf) + local secret = conf.secret + + if conf.base64_secret then + return base64.decode_base64(secret) + end + + return secret +end + +local function load_jwe_token(jwe_token) + local o = {} + o.header, _, o.iv, o.ciphertext, o.tag = jwe_token:match("(.-)%.(.-)%.(.-)%.(.-)%.(.*)") + local dec = base64.decode_base64url + o.header_obj = core.json.decode(dec(o.header)) + o.valid = true + return o +end + +local function jwe_decrypt_with_obj(o, consumer) + local secret = get_secret(consumer.auth_conf) + local dec = base64.decode_base64url + return cipher:decrypt(secret, dec(o.iv), dec(o.ciphertext), false, o.header, dec(o.tag)) +end + +local function jwe_encrypt(o, consumer) + local secret = get_secret(consumer.auth_conf) + local dec = base64.decode_base64url + local enc = base64.encode_base64url + core.log.error("jwe-encrypt: ", secret, o.iv, o.plaintext, o.header) + o.ciphertext = cipher:encrypt(secret, o.iv, o.plaintext, false, o.header) + o.tag = cipher:get_aead_tag() + return o.header .. ".." .. enc(o.iv) .. "." .. enc(o.ciphertext) .. "." .. enc(o.tag) +end + + +local function get_consumer(key) + local consumer_conf = consumer_mod.plugin(plugin_name) + if not consumer_conf then + return nil + end + local consumers = consumer_mod.consumers_kv(plugin_name, consumer_conf, "key") + core.log.info("consumers: ", core.json.delay_encode(consumers)) + return consumers[key] +end + +local function fetch_jwe_token(conf, ctx) + local token = core.request.header(ctx, conf.header) + if token then + local prefix = sub_str(token, 1, 7) + if prefix == 'Bearer ' or prefix == 'bearer ' then + return sub_str(token, 8) + end + + return token + end +end + +function _M.rewrite(conf, ctx) + -- fetch token and hide credentials if necessary + local jwe_token, err = fetch_jwe_token(conf, ctx) + if not jwe_token and conf.strict then + core.log.info("failed to fetch JWE token: ", err) + return 403, { message = "missing JWE token in request" } + end + + local jwe_obj = load_jwe_token(jwe_token) + + if not jwe_obj.valid then + return 400, { message = "JWE token invalid" } + end + + if not jwe_obj.header_obj.kid then + return 400, { message = "missing kid in JWE token" } + end + + local consumer = get_consumer(jwe_obj.header_obj.kid) + if not consumer then + return 400, { message = "invalid kid in JWE token" } + end + + local plaintext, err = jwe_decrypt_with_obj(jwe_obj, consumer) + if err ~= nil then + return 400, { message = "failed to decrypt JWE token" } + end + core.request.set_header(ctx, conf.forward_header, plaintext) +end + +local function gen_token() + local args = core.request.get_uri_args() + if not args or not args.key then + return core.response.exit(400) + end + + local key = args.key + local payload = args.payload + if payload then + payload = ngx.unescape_uri(payload) + end + + local consumer = get_consumer(key) + if not consumer then + return core.response.exit(404) + end + + core.log.info("consumer: ", core.json.delay_encode(consumer)) + + local iv = args.iv + if not iv then + -- TODO: random bytes + iv = "123456789012" + end + + local obj = { + iv = iv, + plaintext = payload, + header_obj = { + kid = key, + alg = "dir", + enc = "A256GCM", + }, + } + obj.header = base64.encode_base64url(core.json.encode(obj.header_obj)) + local jwe_token = jwe_encrypt(obj, consumer) + if jwe_token then + return core.response.exit(200, jwe_token) + end + + return core.response.exit(404) +end + + +function _M.api() + return { + { + methods = { "GET" }, + uri = "/apisix/plugin/jwe/encrypt", + handler = gen_token, + } + } +end + +return _M diff --git a/conf/config-default.yaml b/conf/config-default.yaml index f15f6d1df604c..4e11da24531e6 100755 --- a/conf/config-default.yaml +++ b/conf/config-default.yaml @@ -455,6 +455,7 @@ plugins: # plugin list (sorted by priority) - uri-blocker # priority: 2900 - request-validation # priority: 2800 - chaitin-waf # priority: 2700 + - jwe-decrypt # priority: 2600 - openid-connect # priority: 2599 - cas-auth # priority: 2597 - authz-casbin # priority: 2560 diff --git a/docs/en/latest/plugins/jwe-decrypt.md b/docs/en/latest/plugins/jwe-decrypt.md new file mode 100644 index 0000000000000..a5005f0bc23af --- /dev/null +++ b/docs/en/latest/plugins/jwe-decrypt.md @@ -0,0 +1,191 @@ +--- +title: jwe-decrypt +keywords: + - Apache APISIX + - API Gateway + - Plugin + - JWE Decrypt + - jwe-decrypt +description: This document contains information about the Apache APISIX jwe-decrypt Plugin. +--- + + + +## Description + +The `jwe-decrypt` Plugin is used to decrypt [JWE](https://datatracker.ietf.org/doc/html/rfc7516) authentication header to a [Service](../terminology/service.md) or a [Route](../terminology/route.md). + +A [Consumer](../terminology/consumer.md) of the service then needs to provide a key decrypt the request. + +## Attributes + +For Consumer: + +| Name | Type | Required | Default | Valid values | Description | +|---------------|---------|-------------------------------------------------------|---------|-----------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| key | string | True | | | Unique key for a Consumer. | +| secret | string | True | | | The decrypt key. This field supports saving the value in Secret Manager using the [APISIX Secret](../terminology/secret.md) resource. | +| base64_secret | boolean | False | false | | Set to true if the secret is base64 encoded. | + +For Route: + +| Name | Type | Required | Default | Description | +|--------|--------|----------|---------------|---------------------------------------------------------------------| +| header | string | False | authorization | The header to get the token from. | +| forward_header | string | False | authorization | Set the header name pass the plaintext to the Upstream. | + +## API + +This Plugin adds `/apisix/plugin/jwe/encrypt` as an endpoint. + +:::note + +You may need to use the [public-api](public-api.md) plugin to expose this endpoint. + +::: + +## Enable Plugin + +To enable the Plugin, you have to create a Consumer object with the JWE token and configure your Route to use JWE authentication. + +First, you can create a Consumer object through the Admin API: + +```shell +curl http://127.0.0.1:9180/apisix/admin/consumers -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -d ' +{ + "username": "jack", + "plugins": { + "jwe-decrypt": { + "key": "user-key", + "secret": "keylength-must-32byte-are-you-ok" + } + } +}' +``` + +::: + +Once you have created a Consumer object, you can configure a Route to decrypt the header: + +```shell +curl http://127.0.0.1:9180/apisix/admin/routes/1 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -d ' +{ + "methods": ["GET"], + "uri": "/anything*", + "plugins": { + "jwe-decrypt": {} + }, + "upstream": { + "type": "roundrobin", + "nodes": { + "httpbin.org:80": 1 + } + } +}' +``` + +## Example usage + +You need to first setup a Route for an API that signs the token using the [public-api](public-api.md) Plugin: + +```shell +curl http://127.0.0.1:9180/apisix/admin/routes/jwenew -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -d ' +{ + "uri": "/apisix/plugin/jwe/encrypt", + "plugins": { + "public-api": {} + } +}' +``` + +Now, we can get a token: + +```shell +curl -G --data-urlencode 'payload={"uid":10000,"uname":"test"}' 'http://127.0.0.1:9080/apisix/plugin/jwe/encrypt?key=user-key' -i +``` + +``` +HTTP/1.1 200 OK +Date: Mon, 25 Sep 2023 02:38:16 GMT +Content-Type: text/plain; charset=utf-8 +Transfer-Encoding: chunked +Connection: keep-alive +Server: APISIX/3.5.0 +Apisix-Plugins: public-api + +eyJhbGciOiJkaXIiLCJraWQiOiJ1c2VyLWtleSIsImVuYyI6IkEyNTZHQ00ifQ..MTIzNDU2Nzg5MDEy.hfzMJ0YfmbMcJ0ojgv4PYAHxPjlgMivmv35MiA.7nilnBt2dxLR_O6kf-HQUA +``` + +You can now use this token while making requests: + +```shell +curl http://127.0.0.1:9080/anything/hello -H 'Authorization: eyJhbGciOiJkaXIiLCJraWQiOiJ1c2VyLWtleSIsImVuYyI6IkEyNTZHQ00ifQ..MTIzNDU2Nzg5MDEy.hfzMJ0YfmbMcJ0ojgv4PYAHxPjlgMivmv35MiA.7nilnBt2dxLR_O6kf-HQUA' -i +``` + +You can see header "Authorization" change to plaintext. +``` +HTTP/1.1 200 OK +Content-Type: application/json +Content-Length: 452 +Connection: keep-alive +Date: Mon, 25 Sep 2023 02:38:59 GMT +Access-Control-Allow-Origin: * +Access-Control-Allow-Credentials: true +Server: APISIX/3.5.0 +Apisix-Plugins: jwe-decrypt + +{ + "args": {}, + "data": "", + "files": {}, + "form": {}, + "headers": { + "Accept": "*/*", + "Authorization": "{\"uid\":10000,\"uname\":\"test\"}", + "Host": "127.0.0.1", + "User-Agent": "curl/8.1.2", + "X-Amzn-Trace-Id": "Root=1-6510f2c3-1586ec011a22b5094dbe1896", + "X-Forwarded-Host": "127.0.0.1" + }, + "json": null, + "method": "GET", + "origin": "127.0.0.1, 119.143.79.94", + "url": "http://127.0.0.1/anything/hello" +} +``` + +## Delete Plugin + +To remove the `jwe-decrypt` Plugin, you can delete the corresponding JSON configuration from the Plugin configuration. APISIX will automatically reload and you do not have to restart for this to take effect. + +```shell +curl http://127.0.0.1:9180/apisix/admin/routes/1 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -d ' +{ + "methods": ["GET"], + "uri": "/anything*", + "plugins": {}, + "upstream": { + "type": "roundrobin", + "nodes": { + "httpbin.org:80": 1 + } + } +}' +```