diff --git a/apisix/core/vault.lua b/apisix/core/vault.lua new file mode 100644 index 000000000000..f98926d77491 --- /dev/null +++ b/apisix/core/vault.lua @@ -0,0 +1,122 @@ +-- +-- 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 http = require("resty.http") +local json = require("cjson") + +local fetch_local_conf = require("apisix.core.config_local").local_conf +local norm_path = require("pl.path").normpath + +local _M = {} + +local function fetch_vault_conf() + local conf, err = fetch_local_conf() + if not conf then + return nil, "failed to fetch vault configuration from config yaml: " .. err + end + + if not conf.vault then + return nil, "accessing vault data requires configuration information" + end + return conf.vault +end + + +local function make_request_to_vault(method, key, skip_prefix, data) + local vault, err = fetch_vault_conf() + if not vault then + return nil, err + end + + local httpc = http.new() + -- config timeout or default to 5000 ms + httpc:set_timeout((vault.timeout or 5)*1000) + + local req_addr = vault.host + if not skip_prefix then + req_addr = req_addr .. norm_path("/v1/" + .. vault.prefix .. "/" .. key) + else + req_addr = req_addr .. norm_path("/v1/" .. key) + end + + local res, err = httpc:request_uri(req_addr, { + method = method, + headers = { + ["X-Vault-Token"] = vault.token + }, + body = core.json.encode(data or {}, true) + }) + if not res then + return nil, err + end + + return res.body +end + +-- key is the vault kv engine path, joined with config yaml vault prefix. +-- It takes an extra optional boolean param skip_prefix. If enabled, it simply doesn't use the +-- prefix defined inside config yaml under vault config for fetching data. +local function get(key, skip_prefix) + core.log.info("fetching data from vault for key: ", key) + + local res, err = make_request_to_vault("GET", key, skip_prefix) + if not res or err then + return nil, "failed to retrtive data from vault kv engine " .. err + end + + return json.decode(res) +end + +_M.get = get + +-- key is the vault kv engine path, data is json key vaule pair. +-- It takes an extra optional boolean param skip_prefix. If enabled, it simply doesn't use the +-- prefix defined inside config yaml under vault config for storing data. +local function set(key, data, skip_prefix) + core.log.info("stroing data into vault for key: ", key, + "and value: ", core.json.delay_encode(data, true)) + + local res, err = make_request_to_vault("POST", key, skip_prefix, data) + if not res or err then + return nil, "failed to store data into vault kv engine " .. err + end + + return true +end +_M.set = set + + +-- key is the vault kv engine path, joined with config yaml vault prefix. +-- It takes an extra optional boolean param skip_prefix. If enabled, it simply doesn't use the +-- prefix defined inside config yaml under vault config for deleting data. +local function delete(key, skip_prefix) + core.log.info("deleting data from vault for key: ", key) + + local res, err = make_request_to_vault("DELETE", key, skip_prefix) + + if not res or err then + return nil, "failed to delete data into vault kv engine " .. err + end + + return true +end + +_M.delete = delete + +return _M diff --git a/apisix/plugins/jwt-auth.lua b/apisix/plugins/jwt-auth.lua index cf3152a2a1a7..bf52fa094fae 100644 --- a/apisix/plugins/jwt-auth.lua +++ b/apisix/plugins/jwt-auth.lua @@ -19,6 +19,7 @@ local jwt = require("resty.jwt") local ck = require("resty.cookie") local consumer_mod = require("apisix.consumer") local resty_random = require("resty.random") +local vault = require("apisix.core.vault") local ngx_encode_base64 = ngx.encode_base64 local ngx_decode_base64 = ngx.decode_base64 @@ -54,6 +55,10 @@ local consumer_schema = { base64_secret = { type = "boolean", default = false + }, + vault = { + type = "object", + properties = {} } }, dependencies = { @@ -76,7 +81,20 @@ local consumer_schema = { }, }, required = {"public_key", "private_key"}, - } + }, + { + properties = { + vault = { + type = "object", + properties = {} + }, + algorithm = { + enum = {"RS256"}, + }, + }, + required = {"vault"}, + }, + } } }, @@ -119,29 +137,34 @@ function _M.check_schema(conf, schema_type) if schema_type == core.schema.TYPE_CONSUMER then ok, err = core.schema.check(consumer_schema, conf) else - ok, err = core.schema.check(schema, conf) + return core.schema.check(schema, conf) end if not ok then return false, err end - if schema_type == core.schema.TYPE_CONSUMER then - if conf.algorithm ~= "RS256" and not conf.secret then - conf.secret = ngx_encode_base64(resty_random.bytes(32, true)) - elseif conf.base64_secret then - if ngx_decode_base64(conf.secret) == nil then - return false, "base64_secret required but the secret is not in base64 format" - end + if conf.vault then + core.log.info("skipping jwt-auth schema validation with vault") + return true + end + + if conf.algorithm ~= "RS256" and not conf.secret then + conf.secret = ngx_encode_base64(resty_random.bytes(32, true)) + elseif conf.base64_secret then + if ngx_decode_base64(conf.secret) == nil then + return false, "base64_secret required but the secret is not in base64 format" end + end - if conf.algorithm == "RS256" then - if not conf.public_key then - return false, "missing valid public key" - end - if not conf.private_key then - return false, "missing valid private key" - end + if conf.algorithm == "RS256" then + -- Possible options are a) both are in vault, b) both in schema + -- c) one in schema, another in vault. + if not conf.public_key then + return false, "missing valid public key" + end + if not conf.private_key then + return false, "missing valid private key" end end @@ -175,12 +198,62 @@ local function fetch_jwt_token(ctx) end -local function get_secret(conf) +local function get_vault_path(username) + return "consumer/".. username .. "/jwt-auth" +end + + +local function get_secret(conf, consumer_name) + local secret = conf.secret + if conf.vault then + local res, err = vault.get(get_vault_path(consumer_name)) + if not res or err then + return nil, err + end + + if not res.data or not res.data.secret then + return nil, "secret could not found in vault: " .. core.json.encode(res) + end + secret = res.data.secret + end + if conf.base64_secret then - return ngx_decode_base64(conf.secret) + return ngx_decode_base64(secret) end - return conf.secret + return secret +end + + +local function get_rsa_keypair(conf, consumer_name) + local public_key = conf.public_key + local private_key = conf.private_key + -- if keys are present in conf, no need to query vault (fallback) + if public_key and private_key then + return public_key, private_key + end + + local vout = {} + if conf.vault then + local res, err = vault.get(get_vault_path(consumer_name)) + if not res or err then + return nil, nil, err + end + + if not res.data then + return nil, nil, "keypairs could not found in vault: " .. core.json.encode(res) + end + vout = res.data + end + + if not public_key and not vout.public_key then + return nil, nil, "missing public key, not found in config/vault" + end + if not private_key and not vout.private_key then + return nil, nil, "missing private key, not found in config/vault" + end + + return public_key or vout.public_key, private_key or vout.private_key end @@ -197,16 +270,20 @@ local function get_real_payload(key, auth_conf, payload) end -local function sign_jwt_with_HS(key, auth_conf, payload) - local auth_secret = get_secret(auth_conf) +local function sign_jwt_with_HS(key, consumer, payload) + local auth_secret, err = get_secret(consumer.auth_conf, consumer.username) + if not auth_secret then + core.log.error("failed to sign jwt, err: ", err) + core.response.exit(503, "failed to sign jwt") + end local ok, jwt_token = pcall(jwt.sign, _M, auth_secret, { header = { typ = "JWT", - alg = auth_conf.algorithm + alg = consumer.auth_conf.algorithm }, - payload = get_real_payload(key, auth_conf, payload) + payload = get_real_payload(key, consumer.auth_conf, payload) } ) if not ok then @@ -217,18 +294,24 @@ local function sign_jwt_with_HS(key, auth_conf, payload) end -local function sign_jwt_with_RS256(key, auth_conf, payload) +local function sign_jwt_with_RS256(key, consumer, payload) + local public_key, private_key, err = get_rsa_keypair(consumer.auth_conf, consumer.username) + if not public_key then + core.log.error("failed to sign jwt, err: ", err) + core.response.exit(503, "failed to sign jwt") + end + local ok, jwt_token = pcall(jwt.sign, _M, - auth_conf.private_key, + private_key, { header = { typ = "JWT", - alg = auth_conf.algorithm, + alg = consumer.auth_conf.algorithm, x5c = { - auth_conf.public_key, + public_key, } }, - payload = get_real_payload(key, auth_conf, payload) + payload = get_real_payload(key, consumer.auth_conf, payload) } ) if not ok then @@ -238,13 +321,22 @@ local function sign_jwt_with_RS256(key, auth_conf, payload) return jwt_token end - -local function algorithm_handler(consumer) +-- introducing method_only flag (returns respective signing method) to save http API calls. +local function algorithm_handler(consumer, method_only) if not consumer.auth_conf.algorithm or consumer.auth_conf.algorithm == "HS256" or consumer.auth_conf.algorithm == "HS512" then - return sign_jwt_with_HS, get_secret(consumer.auth_conf) + if method_only then + return sign_jwt_with_HS + end + + return get_secret(consumer.auth_conf, consumer.username) elseif consumer.auth_conf.algorithm == "RS256" then - return sign_jwt_with_RS256, consumer.auth_conf.public_key + if method_only then + return sign_jwt_with_RS256 + end + + local public_key, _, err = get_rsa_keypair(consumer.auth_conf, consumer.username) + return public_key, err end end @@ -284,7 +376,11 @@ function _M.rewrite(conf, ctx) end core.log.info("consumer: ", core.json.delay_encode(consumer)) - local _, auth_secret = algorithm_handler(consumer) + local auth_secret, err = algorithm_handler(consumer) + if not auth_secret then + core.log.error("failed to retrive secrets, err: ", err) + return 503, {message = "failed to verify jwt"} + end jwt_obj = jwt:verify_jwt_obj(auth_secret, jwt_obj) core.log.info("jwt object: ", core.json.delay_encode(jwt_obj)) @@ -325,8 +421,8 @@ local function gen_token() core.log.info("consumer: ", core.json.delay_encode(consumer)) - local sign_handler, _ = algorithm_handler(consumer) - local jwt_token = sign_handler(key, consumer.auth_conf, payload) + local sign_handler = algorithm_handler(consumer, true) + local jwt_token = sign_handler(key, consumer, payload) if jwt_token then return core.response.exit(200, jwt_token) end diff --git a/ci/centos7-ci.sh b/ci/centos7-ci.sh index f5a17996dc6f..c620417647bf 100755 --- a/ci/centos7-ci.sh +++ b/ci/centos7-ci.sh @@ -40,6 +40,9 @@ install_dependencies() { cp ./etcd-v3.4.0-linux-amd64/etcdctl /usr/local/bin/ rm -rf etcd-v3.4.0-linux-amd64 + # install vault cli capabilities + install_vault_cli + # install test::nginx yum install -y cpanminus perl cpanm --notest Test::Nginx IPC::Run > build.log 2>&1 || (cat build.log && exit 1) diff --git a/ci/common.sh b/ci/common.sh index f27583b3b495..ec8b7e6ae6c5 100644 --- a/ci/common.sh +++ b/ci/common.sh @@ -39,4 +39,10 @@ install_grpcurl () { tar -xvf grpcurl_${GRPCURL_VERSION}_linux_x86_64.tar.gz -C /usr/local/bin } +install_vault_cli () { + VAULT_VERSION="1.9.0" + wget https://releases.hashicorp.com/vault/${VAULT_VERSION}/vault_${VAULT_VERSION}_linux_amd64.zip + unzip vault_${VAULT_VERSION}_linux_amd64.zip && mv ./vault /usr/local/bin +} + GRPC_SERVER_EXAMPLE_VER=20210819 diff --git a/ci/linux-ci-init-service.sh b/ci/linux-ci-init-service.sh index 2939e827d2bf..765c1155a111 100755 --- a/ci/linux-ci-init-service.sh +++ b/ci/linux-ci-init-service.sh @@ -32,6 +32,9 @@ docker exec -i rmqnamesrv /home/rocketmq/rocketmq-4.6.0/bin/mqadmin updateTopic docker exec -i rmqnamesrv /home/rocketmq/rocketmq-4.6.0/bin/mqadmin updateTopic -n rocketmq_namesrv:9876 -t test3 -c DefaultCluster docker exec -i rmqnamesrv /home/rocketmq/rocketmq-4.6.0/bin/mqadmin updateTopic -n rocketmq_namesrv:9876 -t test4 -c DefaultCluster +# prepare vault kv engine +docker exec -i vault sh -c "VAULT_TOKEN='root' VAULT_ADDR='http://0.0.0.0:8200' vault secrets enable -path=kv -version=1 kv" + # prepare OPA env curl -XPUT 'http://localhost:8181/v1/policies/example' \ --header 'Content-Type: text/plain' \ diff --git a/ci/linux_openresty_common_runner.sh b/ci/linux_openresty_common_runner.sh index 7916d1f95bdc..98a9be25576a 100755 --- a/ci/linux_openresty_common_runner.sh +++ b/ci/linux_openresty_common_runner.sh @@ -54,8 +54,11 @@ do_install() { CGO_ENABLED=0 go build cd ../../ - # installing grpcurl + # install grpcurl install_grpcurl + + # install vault cli capabilities + install_vault_cli } script() { diff --git a/ci/pod/docker-compose.yml b/ci/pod/docker-compose.yml index 1055372b660d..b632a59c7e7c 100644 --- a/ci/pod/docker-compose.yml +++ b/ci/pod/docker-compose.yml @@ -136,6 +136,23 @@ services: consul_net: + ## HashiCorp Vault + vault: + image: vault:1.9.0 + container_name: vault + restart: unless-stopped + ports: + - "8200:8200" + cap_add: + - IPC_LOCK + environment: + VAULT_DEV_ROOT_TOKEN_ID: root + VAULT_DEV_LISTEN_ADDRESS: 0.0.0.0:8200 + command: [ "vault", "server", "-dev" ] + networks: + vault_net: + + ## OpenLDAP openldap: image: bitnami/openldap:2.5.8 @@ -396,4 +413,5 @@ networks: nacos_net: skywalk_net: rocketmq_net: + vault_net: opa_net: diff --git a/conf/config-default.yaml b/conf/config-default.yaml index 0a9dec0e74cd..b53b32ba6191 100644 --- a/conf/config-default.yaml +++ b/conf/config-default.yaml @@ -286,6 +286,19 @@ etcd: # the default value is true, e.g. the certificate will be verified strictly. #sni: # the SNI for etcd TLS requests. If missed, the host part of the URL will be used. +# HashiCorp Vault storage backend for sensitive data retrieval. The config shows an example of what APISIX expects if you +# wish to integrate Vault for secret (sensetive string, public private keys etc.) retrieval. APISIX communicates with Vault +# server HTTP APIs. By default, APISIX doesn't need this configuration. +# vault: +# host: "http://0.0.0.0:8200" # The host address where the vault server is running. +# timeout: 10 # request timeout 30 seconds +# token: root # Authentication token to access Vault HTTP APIs +# prefix: kv/apisix # APISIX supports vault kv engine v1, where sensitive data are being stored + # and retrieved through vault HTTP APIs. enabling a prefix allows you to better enforcement of + # policies, generate limited scoped tokens and tightly control the data that can be accessed + # from APISIX. + + #discovery: # service discovery center # dns: # servers: diff --git a/docs/en/latest/plugins/jwt-auth.md b/docs/en/latest/plugins/jwt-auth.md index 1ed30cedcc40..a5bad8bb84ac 100644 --- a/docs/en/latest/plugins/jwt-auth.md +++ b/docs/en/latest/plugins/jwt-auth.md @@ -23,14 +23,16 @@ title: jwt-auth ## Summary -- [**Name**](#name) -- [**Attributes**](#attributes) -- [**API**](#api) -- [**How To Enable**](#how-to-enable) -- [**Test Plugin**](#test-plugin) - - [get the token in `jwt-auth` plugin:](#get-the-token-in-jwt-auth-plugin) - - [try request with token](#try-request-with-token) -- [**Disable Plugin**](#disable-plugin) +- [Summary](#summary) +- [Name](#name) +- [Attributes](#attributes) +- [API](#api) +- [How To Enable](#how-to-enable) + - [Enable jwt-auth with Vault Compatibility](#enable-jwt-auth-with-vault-compatibility) +- [Test Plugin](#test-plugin) + - [Get the Token in `jwt-auth` Plugin:](#get-the-token-in-jwt-auth-plugin) + - [Try Request with Token](#try-request-with-token) +- [Disable Plugin](#disable-plugin) ## Name @@ -40,6 +42,8 @@ The `consumer` then adds its key to the query string parameter, request header, For more information on JWT, refer to [JWT](https://jwt.io/) for more information. +`jwt-auth` plugin can be integrated with HashiCorp Vault for storing and fetching secrets, RSA key pairs from its encrypted kv engine. See the [examples](#enable-jwt-auth-with-vault-compatibility) below to have an overview of how things work. + ## Attributes | Name | Type | Requirement | Default | Valid | Description | @@ -51,6 +55,9 @@ For more information on JWT, refer to [JWT](https://jwt.io/) for more informatio | algorithm | string | optional | "HS256" | ["HS256", "HS512", "RS256"] | encryption algorithm. | | exp | integer | optional | 86400 | [1,...] | token's expire time, in seconds | | base64_secret | boolean | optional | false | | whether secret is base64 encoded | +| vault | object | optional | | | whether vault to be used for secret (secret for HS256/HS512 or public_key and private_key for RS256) storage and retrieval. The plugin by default uses the vault path as `kv/apisix/consumer//jwt-auth` for secret retrieval. | + +**Note**: To enable vault integration, first visit the [config.yaml](https://github.com/apache/apisix/blob/master/conf/config.yaml) update it with your vault server configuration, host address and access token. You can take a look of what APISIX expects from the config.yaml at [config-default.yaml](https://github.com/apache/apisix/blob/master/conf/config-default.yaml) under the vault attributes. ## API @@ -110,6 +117,68 @@ curl http://127.0.0.1:9080/apisix/admin/routes/1 -H 'X-API-KEY: edd1c9f034335f13 }' ``` +### Enable jwt-auth with Vault Compatibility + +Sometimes, it's quite natural in production to have a centralized key management solution like vault where you don't have to update the APISIX consumer each time some part of your organization changes the signing secret key (secret for HS256/HS512 or public_key and private_key for RS256) and/or for privacy concerns you don't want to use the key through APISIX admin APIs. APISIX got you covered here. The `jwt-auth` is capable of referencing keys from vault. + +**Note**: For early version of this integration support, the plugin expects the key name of secrets stored into the vault path is among [ `secret`, `public_key`, `private_key` ] to successfully use the key. In future releases, we are going to add the support of referencing custom named keys. + +To enable vault compatibility, just add the empty vault object inside the jwt-auth plugin. + +1. You have stored HS256 signing secret inside vault and you want to use it for jwt signing and verification. + +```shell +curl http://127.0.0.1:9080/apisix/admin/consumers -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -d ' +{ + "username": "jack", + "plugins": { + "jwt-auth": { + "key": "key-1", + "vault": {} + } + } +}' +``` + +Here the plugin looks up for key `secret` inside vault path (`/consumer/jack/jwt-auth`) for consumer username `jack` mentioned in the consumer config and uses it for subsequent signing and jwt verification. If the key is not found in the same path, the plugin logs error and fails to perform jwt authentication. + +2. RS256 rsa keypairs, both public and private keys are stored into vault. + +```shell +curl http://127.0.0.1:9080/apisix/admin/consumers -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -d ' +{ + "username": "kowalski", + "plugins": { + "jwt-auth": { + "key": "rsa-keypair", + "algorithm": "RS256", + "vault": {} + } + } +}' +``` + +The plugin looks up for `public_key` and `private_key` keys inside vault kv path (`/consumer/kowalski/jwt-auth`) for username `kowalski` mentioned inside plugin vault configuration. If not found, authentication fails. + +3. public key in consumer configuration, while the private key is in vault. + +```shell +curl http://127.0.0.1:9080/apisix/admin/consumers -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -d ' +{ + "username": "rico", + "plugins": { + "jwt-auth": { + "key": "user-key", + "algorithm": "RS256", + "public_key": "-----BEGIN PUBLIC KEY-----\n……\n-----END PUBLIC KEY-----" + "vault": {} + } + } +}' +``` + +This plugin uses rsa public key from consumer configuration and uses the private key directly fetched from vault. + You can use [APISIX Dashboard](https://github.com/apache/apisix-dashboard) to complete the above operations through the web console. 1. Add a Consumer through the web console: @@ -125,7 +194,7 @@ then add jwt-auth plugin in the Consumer page: ## Test Plugin -#### get the token in `jwt-auth` plugin: +#### Get the Token in `jwt-auth` Plugin: * without extension payload: @@ -155,7 +224,7 @@ Server: APISIX/2.4 eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1bmFtZSI6InRlc3QiLCJ1aWQiOjEwMDAwLCJrZXkiOiJ1c2VyLWtleSIsImV4cCI6MTYxOTA3MzgzOX0.jI9-Rpz1gc3u8Y6lZy8I43RXyCu0nSHANCvfn0YZUCY ``` -#### try request with token +#### Try Request with Token * without token: diff --git a/t/certs/private.pem b/t/certs/private.pem new file mode 100644 index 000000000000..76f0875f9540 --- /dev/null +++ b/t/certs/private.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEA79XYBopfnVMKxI533oU2VFQbEdSPtWRD+xSl73lHLVboGP1l +SIZtnEj5AcTN2uDW6AYPiWL2iA3lEEsDTs7JBUXyl6pysBPfrqC8n/MOXKaD4e8U +5GAHFiwHWg2WzHlfFSlFkLjzp0vPkDK+fQ4Clrd7shAyitB7use6DHcVCKuI4bFO +oFbdI5sBGeyoD833g+ql9bRkH/vf8O+rPwHAM+47r1iv3lY3ex0P45PRd7U7rq8P +8UIw6qOI1tiYuKlFJmjFdcwtYG0dctxWwgL1+7njrVQoWvuOTSsc9TDMhZkmmSsU +3wXjaPxJpydck1C/w9ZLqsctKK5swYWhIcbcBQIDAQABAoIBADHXy1FwqHZVr8Mx +qI/CN4xG/mkyN7uG3unrXKDsH3K4wPuQjeAIr/bu43EOqYl3eLI3sDrpKjsUSCqe +rE1QhE5oPwZuEe+t8aqlFQ5YwP9YS8hEm57qpg5hkBWTBWfxQWVwclilV13JT5W0 +NgpfQwJ3l2lmHFrlARHMOEom5WQrewKvLh2YXeJBFQc0shHcjC2Pt7cjR9oAUVi6 +M5h6I+eB5xd9jj2a2fXaFL1SKZXEBVT6agSQqdB0tSuVTUsTBzNnuTL5ngS1wdLa +lEdrw8klOYWrUihKJgYH7rnQrVEVNxGyO6fVs1S9CxMwu/nW2MPcbRBY0WKYCcAO +QFJ4j4ECgYEA+yaEEPp/SH1E+DJi3U35pGdlHqg8yP0R7sik2cvvPUk4VbPrYVDD +NQ8gt2H+06keycfRqJTPptS79db9LpKjG59yYP3aWj2YbGsH1H3XxA3sZiWHkNl0 +7i0ZE0GSCmEMbPe3C0Z3726tD9ZyVdaE5RdvRWdz1IloA+rYr3ypnH0CgYEA9Hdl +KY8qSthtgWsTuthpExcvfppS3Dijgd23+oZJY2JLKf8/yctuBv6rBgqDCwpnUmGR +tnkxPD/igaBnFtaMjDKNMwWwGHyarWkI7Zc+6HUdNcA/BkI3MCxwYQg2fr7HXY0h +FalewOHeJz2Tldaue9DrVIO49jfLtBh2DYZFvCkCgYBV7OmGPY3KqUEtgV+dw43D +l7Ra9shFI4A9J9xuv30MhL6HY9UGKHGA97oDw71BgT0NYBX1DWS1+VaNV46rnnO7 +gaPKV0+bTDOX9E5rftqRMwpMME7fWebNjhRkKCzk7CsqJN41N1jVTBJdtsrLX2d8 +UbY6EpjogFJb9L9J2ubUqQKBgQCk6oKJJbZfJV/CJaz6qBFCOqrkmlD5lQ/ghOUf +EUYi0GVqYHH0vNJtz5EqEx9R7GPFNGLrGRi4z1QLJF1HD9dioJuWZujjq/NgtnG6 +bgSXJqJc52Lc4wB99AyfuL2ihSrTFmjSRx7Puc9241hTha7Rgh+vNOkq2HsH9FR3 +TTRv+QKBgG5ph+SFenSE7MgYXm2NRfG1k8bp86hrt9C8vHJ7DSO2Rr833RtqEiDJ +nD4FbR0IObaBpS2VJdOn/jBYXCG0hFuj+Shxiyg/mZN0fwPVaRWDls7jzqqPsA+b +x3XKRAn57LY8UbsNpOIqZ8kjVLPZhgfYwfOI3yAeSMv4ZnRY/MWe +-----END RSA PRIVATE KEY----- diff --git a/t/certs/public.pem b/t/certs/public.pem new file mode 100644 index 000000000000..f122f85bb735 --- /dev/null +++ b/t/certs/public.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA79XYBopfnVMKxI533oU2 +VFQbEdSPtWRD+xSl73lHLVboGP1lSIZtnEj5AcTN2uDW6AYPiWL2iA3lEEsDTs7J +BUXyl6pysBPfrqC8n/MOXKaD4e8U5GAHFiwHWg2WzHlfFSlFkLjzp0vPkDK+fQ4C +lrd7shAyitB7use6DHcVCKuI4bFOoFbdI5sBGeyoD833g+ql9bRkH/vf8O+rPwHA +M+47r1iv3lY3ex0P45PRd7U7rq8P8UIw6qOI1tiYuKlFJmjFdcwtYG0dctxWwgL1 ++7njrVQoWvuOTSsc9TDMhZkmmSsU3wXjaPxJpydck1C/w9ZLqsctKK5swYWhIcbc +BQIDAQAB +-----END PUBLIC KEY----- diff --git a/t/plugin/jwt-auth-vault.t b/t/plugin/jwt-auth-vault.t new file mode 100644 index 000000000000..c7d9e421c835 --- /dev/null +++ b/t/plugin/jwt-auth-vault.t @@ -0,0 +1,362 @@ +# +# 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. +# +use t::APISIX 'no_plan'; + +repeat_each(1); +no_long_string(); +no_root_location(); +no_shuffle(); + +add_block_preprocessor(sub { + my ($block) = @_; + + my $http_config = $block->http_config // <<_EOC_; + + server { + listen 8777; + + location /secure-endpoint { + content_by_lua_block { + ngx.say("successfully invoked secure endpoint") + } + } + } +_EOC_ + + $block->set_value("http_config", $http_config); + + my $vault_config = $block->extra_yaml_config // <<_EOC_; +vault: + host: "http://0.0.0.0:8200" + timeout: 10 + prefix: kv/apisix + token: root +_EOC_ + + $block->set_value("extra_yaml_config", $vault_config); + + if (!$block->request) { + $block->set_value("request", "GET /t"); + } + if (!$block->no_error_log && !$block->error_log) { + $block->set_value("no_error_log", "[error]\n[alert]"); + } +}); + +run_tests; + +__DATA__ + +=== TEST 1: schema check +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.jwt-auth") + local core = require("apisix.core") + for _, conf in ipairs({ + { + -- public and private key are not provided for RS256, returns error + key = "key-1", + algorithm = "RS256" + }, + { + -- public and private key are not provided but vault config is enabled. + key = "key-1", + algorithm = "RS256", + vault = {} + } + }) do + local ok, err = plugin.check_schema(conf, core.schema.TYPE_CONSUMER) + if not ok then + ngx.say(err) + else + ngx.say("ok") + end + end + } + } +--- response_body +failed to validate dependent schema for "algorithm": value should match only one schema, but matches none +ok + + + +=== TEST 2: create a consumer with plugin and username +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/consumers', + ngx.HTTP_PUT, + [[{ + "username": "jack", + "plugins": { + "jwt-auth": { + "key": "key-hs256", + "algorithm": "HS256", + "vault":{} + } + } + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 3: enable jwt auth plugin using admin api +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "plugins": { + "jwt-auth": {} + }, + "upstream": { + "nodes": { + "127.0.0.1:8777": 1 + }, + "type": "roundrobin" + }, + "uri": "/secure-endpoint" + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 4: sign a jwt and access/verify /secure-endpoint, fails as no secret entry into vault +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, err, sign = t('/apisix/plugin/jwt/sign?key=key-hs256', + ngx.HTTP_GET + ) + + if code > 200 then + ngx.status = code + ngx.say(err) + return + end + + local code, _, res = t('/secure-endpoint?jwt=' .. sign, + ngx.HTTP_GET + ) + if code >= 300 then + ngx.status = code + end + ngx.print(res) + } + } +--- response_body +failed to sign jwt +--- error_code: 503 +--- error_log eval +qr/failed to sign jwt, err: secret could not found in vault/ +--- grep_error_log_out +failed to sign jwt, err: secret could not found in vault + + + +=== TEST 5: store HS256 secret into vault +--- exec +VAULT_TOKEN='root' VAULT_ADDR='http://0.0.0.0:8200' vault kv put kv/apisix/consumer/jack/jwt-auth secret=$3nsitiv3-c8d3 +--- response_body +Success! Data written to: kv/apisix/consumer/jack/jwt-auth + + + +=== TEST 6: sign a HS256 jwt and access/verify /secure-endpoint +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, err, sign = t('/apisix/plugin/jwt/sign?key=key-hs256', + ngx.HTTP_GET + ) + + if code > 200 then + ngx.status = code + ngx.say(err) + return + end + + local code, _, res = t('/secure-endpoint?jwt=' .. sign, + ngx.HTTP_GET + ) + if code >= 300 then + ngx.status = code + end + ngx.print(res) + } + } +--- response_body +successfully invoked secure endpoint + + + +=== TEST 7: store rsa key pairs into vault from local filesystem +--- exec +VAULT_TOKEN='root' VAULT_ADDR='http://0.0.0.0:8200' vault kv put kv/apisix/consumer/jim/jwt-auth public_key=@t/certs/public.pem private_key=@t/certs/private.pem +--- response_body +Success! Data written to: kv/apisix/consumer/jim/jwt-auth + + + +=== TEST 8: create consumer for RS256 algorithm with keypair fetched from vault +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/consumers', + ngx.HTTP_PUT, + [[{ + "username": "jim", + "plugins": { + "jwt-auth": { + "key": "rsa", + "algorithm": "RS256", + "vault":{} + } + } + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 9: sign a jwt with with rsa keypair and access /secure-endpoint +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, err, sign = t('/apisix/plugin/jwt/sign?key=rsa', + ngx.HTTP_GET + ) + + if code > 200 then + ngx.status = code + ngx.say(err) + return + end + + local code, _, res = t('/secure-endpoint?jwt=' .. sign, + ngx.HTTP_GET + ) + if code >= 300 then + ngx.status = code + end + ngx.print(res) + } + } +--- response_body +successfully invoked secure endpoint + + + +=== TEST 10: store rsa private key into vault from local filesystem +--- exec +VAULT_TOKEN='root' VAULT_ADDR='http://0.0.0.0:8200' vault kv put kv/apisix/consumer/john/jwt-auth private_key=@t/certs/private.pem +--- response_body +Success! Data written to: kv/apisix/consumer/john/jwt-auth + + + +=== TEST 11: create consumer for RS256 algorithm with private key fetched from vault and public key in consumer schema +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/consumers', + ngx.HTTP_PUT, + [[{ + "username": "john", + "plugins": { + "jwt-auth": { + "key": "rsa1", + "algorithm": "RS256", + "public_key": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA79XYBopfnVMKxI533oU2\nVFQbEdSPtWRD+xSl73lHLVboGP1lSIZtnEj5AcTN2uDW6AYPiWL2iA3lEEsDTs7J\nBUXyl6pysBPfrqC8n/MOXKaD4e8U5GAHFiwHWg2WzHlfFSlFkLjzp0vPkDK+fQ4C\nlrd7shAyitB7use6DHcVCKuI4bFOoFbdI5sBGeyoD833g+ql9bRkH/vf8O+rPwHA\nM+47r1iv3lY3ex0P45PRd7U7rq8P8UIw6qOI1tiYuKlFJmjFdcwtYG0dctxWwgL1\n+7njrVQoWvuOTSsc9TDMhZkmmSsU3wXjaPxJpydck1C/w9ZLqsctKK5swYWhIcbc\nBQIDAQAB\n-----END PUBLIC KEY-----\n", + "vault":{} + } + } + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 12: sign a jwt with with rsa keypair and access /secure-endpoint +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, err, sign = t('/apisix/plugin/jwt/sign?key=rsa1', + ngx.HTTP_GET + ) + + if code > 200 then + ngx.status = code + ngx.say(err) + return + end + + local code, _, res = t('/secure-endpoint?jwt=' .. sign, + ngx.HTTP_GET + ) + if code >= 300 then + ngx.status = code + end + ngx.print(res) + } + } +--- response_body +successfully invoked secure endpoint