From 5a9795b6e4e3e388d05ebe18bab609b86774fd96 Mon Sep 17 00:00:00 2001 From: spacewander Date: Mon, 12 Apr 2021 18:14:36 +0800 Subject: [PATCH] feat: support client certificate verification Signed-off-by: spacewander --- apisix/admin/ssl.lua | 11 + apisix/init.lua | 13 ++ apisix/schema_def.lua | 12 ++ apisix/ssl.lua | 10 + apisix/ssl/router/radixtree_sni.lua | 21 +- docs/en/latest/admin-api.md | 2 + docs/zh/latest/admin-api.md | 2 + t/node/client-mtls.t | 310 ++++++++++++++++++++++++++++ 8 files changed, 380 insertions(+), 1 deletion(-) create mode 100644 t/node/client-mtls.t diff --git a/apisix/admin/ssl.lua b/apisix/admin/ssl.lua index 943c4bd1414b9..96820b22afcb9 100644 --- a/apisix/admin/ssl.lua +++ b/apisix/admin/ssl.lua @@ -70,6 +70,17 @@ local function check_conf(id, conf, need_id) end end + if conf.client then + if not apisix_ssl.support_client_verfication() then + return nil, {error_msg = "client tls verify unsupported"} + end + + local ok, err = apisix_ssl.validate(conf.client.ca, nil) + if not ok then + return nil, {error_msg = "failed to validate client_cert: " .. err} + end + end + return need_id and id or true end diff --git a/apisix/init.lua b/apisix/init.lua index 2fea8416e9e08..cc40858b4ab48 100644 --- a/apisix/init.lua +++ b/apisix/init.lua @@ -277,6 +277,19 @@ end function _M.http_access_phase() local ngx_ctx = ngx.ctx + + if ngx_ctx.api_ctx and ngx_ctx.api_ctx.ssl_client_verified then + local res = ngx_var.ssl_client_verify + if res ~= "SUCCESS" then + if res == "NONE" then + core.log.error("client certificate was not present") + else + core.log.error("clent certificate verification is not passed: ", res) + end + return core.response.exit(400) + end + end + -- always fetch table from the table pool, we don't need a reused api_ctx local api_ctx = core.tablepool.fetch("api_ctx", 0, 32) ngx_ctx.api_ctx = api_ctx diff --git a/apisix/schema_def.lua b/apisix/schema_def.lua index b60faad0d56ad..809f55646afc3 100644 --- a/apisix/schema_def.lua +++ b/apisix/schema_def.lua @@ -642,6 +642,18 @@ _M.ssl = { type = "array", items = private_key_schema, }, + client = { + type = "object", + properties = { + ca = certificate_scheme, + depth = { + type = "integer", + minimum = 0, + default = 1, + }, + }, + required = {"ca"}, + }, exptime = { type = "integer", minimum = 1588262400, -- 2020/5/1 0:0:0 diff --git a/apisix/ssl.lua b/apisix/ssl.lua index a3b9a962828a0..72d870709310b 100644 --- a/apisix/ssl.lua +++ b/apisix/ssl.lua @@ -101,6 +101,11 @@ function _M.validate(cert, key) return nil, "failed to parse cert: " .. err end + if key == nil then + -- sometimes we only need to validate the cert + return true + end + key = aes_decrypt_pkey(key) if not key then return nil, "failed to decrypt previous encrypted key" @@ -152,4 +157,9 @@ function _M.fetch_pkey(sni, pkey) end +function _M.support_client_verfication() + return ngx_ssl.verify_client ~= nil +end + + return _M diff --git a/apisix/ssl/router/radixtree_sni.lua b/apisix/ssl/router/radixtree_sni.lua index fe0bf357dea85..648eafd4918cc 100644 --- a/apisix/ssl/router/radixtree_sni.lua +++ b/apisix/ssl/router/radixtree_sni.lua @@ -20,7 +20,7 @@ local core = require("apisix.core") local apisix_ssl = require("apisix.ssl") local ngx_ssl = require("ngx.ssl") local config_util = require("apisix.core.config_util") -local ipairs = ipairs +local ipairs = ipairs local type = type local error = error local str_find = core.string.find @@ -29,6 +29,7 @@ local ssl_certificates local radixtree_router local radixtree_router_ver + local _M = { version = 0.1, server_name = ngx_ssl.server_name, @@ -194,6 +195,24 @@ function _M.match_and_set(api_ctx) end end + if matched_ssl.value.client then + local client_cert = matched_ssl.value.client.ca + local depth = matched_ssl.value.client.depth + if apisix_ssl.support_client_verfication() then + local parsed_cert, err = apisix_ssl.fetch_cert(sni, client_cert) + if not parsed_cert then + return false, "failed to parse client cert: " .. err + end + + local ok, err = ngx_ssl.verify_client(parsed_cert, depth) + if not ok then + return false, err + end + + api_ctx.ssl_client_verified = true + end + end + return true end diff --git a/docs/en/latest/admin-api.md b/docs/en/latest/admin-api.md index d48079be2f48d..099afd51b3fca 100644 --- a/docs/en/latest/admin-api.md +++ b/docs/en/latest/admin-api.md @@ -784,6 +784,8 @@ Return response from etcd currently. | key | True | Private key | https private key | | | certs | False | An array of certificate | when you need to configure multiple certificate for the same domain, you can pass extra https certificates (excluding the one given as cert) in this field | | | keys | False | An array of private key | https private keys. The keys should be paired with certs above | | +| client.ca | False | Certificate| set the CA certificate which will use to verify client. This feature requires OpenResty 1.19+. | | +| client.depth | False | Certificate| set the verification depth in the client certificates chain, default to 1. This feature requires OpenResty 1.19+. | | | snis | True | Match Rules | a non-empty arrays of https SNI | | | labels | False | Match Rules | Key/value pairs to specify attributes | {"version":"v2","build":"16","env":"production"} | | create_time | False | Auxiliary | epoch timestamp in second, will be created automatically if missing | 1602883670 | diff --git a/docs/zh/latest/admin-api.md b/docs/zh/latest/admin-api.md index d66097606904b..d83d750535948 100644 --- a/docs/zh/latest/admin-api.md +++ b/docs/zh/latest/admin-api.md @@ -781,6 +781,8 @@ $ curl http://127.0.0.1:9080/get | key | 必需 | 私钥 | https 证书私钥 | | | certs | 可选 | 证书字符串数组 | 当你想给同一个域名配置多个证书时,除了第一个证书需要通过 cert 传递外,剩下的证书可以通过该参数传递上来 | | | keys | 可选 | 私钥字符串数组 | certs 对应的证书私钥,注意要跟 certs 一一对应 | | +| client.ca | 可选 | 证书| 设置将用于客户端证书校验的 CA 证书。该特性需要 OpenResty 1.19+ | | +| client.depth | 可选 | 辅助| 设置客户端证书校验的深度,默认为 1。该特性需要 OpenResty 1.19+ | | | snis | 必需 | 匹配规则 | 非空数组形式,可以匹配多个 SNI | | | labels | 可选 | 匹配规则 | 标识附加属性的键值对 | {"version":"v2","build":"16","env":"production"} | | create_time | 可选 | 辅助 | 单位为秒的 epoch 时间戳,如果不指定则自动创建 | 1602883670 | diff --git a/t/node/client-mtls.t b/t/node/client-mtls.t new file mode 100644 index 0000000000000..4820192906256 --- /dev/null +++ b/t/node/client-mtls.t @@ -0,0 +1,310 @@ +# +# 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; + +my $nginx_binary = $ENV{'TEST_NGINX_BINARY'} || 'nginx'; +my $version = eval { `$nginx_binary -V 2>&1` }; + +if ($version !~ m/\/apisix-nginx-module/) { + plan(skip_all => "apisix-nginx-module not installed"); +} else { + plan('no_plan'); +} + +repeat_each(1); +log_level('info'); +no_root_location(); +no_shuffle(); + +add_block_preprocessor(sub { + my ($block) = @_; + + if ((!defined $block->error_log) && (!defined $block->no_error_log)) { + $block->set_value("no_error_log", "[error]"); + } +}); + +run_tests(); + +__DATA__ + +=== TEST 1: bad client certificate +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin") + local json = require("toolkit.json") + local ssl_cert = t.read_file("t/certs/mtls_client.crt") + local ssl_key = t.read_file("t/certs/mtls_client.key") + local data = { + cert = ssl_cert, + key = ssl_key, + sni = "test.com", + client = { + ca = ("test.com"):rep(128), + } + } + local code, body = t.test('/apisix/admin/ssl/1', + ngx.HTTP_PUT, + json.encode(data) + ) + + if code >= 300 then + ngx.status = code + end + ngx.print(body) + } + } +--- request +GET /t +--- error_code: 400 +--- response_body +{"error_msg":"failed to validate client_cert: failed to parse cert: PEM_read_bio_X509_AUX() failed"} + + + +=== TEST 2: missing client certificate +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin") + local json = require("toolkit.json") + local ssl_cert = t.read_file("t/certs/mtls_client.crt") + local ssl_key = t.read_file("t/certs/mtls_client.key") + local data = { + cert = ssl_cert, + key = ssl_key, + sni = "test.com", + client = { + } + } + local code, body = t.test('/apisix/admin/ssl/1', + ngx.HTTP_PUT, + json.encode(data) + ) + + if code >= 300 then + ngx.status = code + end + ngx.print(body) + } + } +--- request +GET /t +--- error_code: 400 +--- response_body +{"error_msg":"invalid configuration: property \"client\" validation failed: property \"ca\" is required"} + + + +=== TEST 3: set verification +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin") + local json = require("toolkit.json") + local ssl_ca_cert = t.read_file("t/certs/mtls_ca.crt") + local ssl_cert = t.read_file("t/certs/mtls_client.crt") + local ssl_key = t.read_file("t/certs/mtls_client.key") + local data = { + upstream = { + scheme = "https", + type = "roundrobin", + nodes = { + ["127.0.0.1:1994"] = 1, + }, + tls = { + client_cert = ssl_cert, + client_key = ssl_key, + } + }, + plugins = { + ["proxy-rewrite"] = { + uri = "/hello" + } + }, + uri = "/mtls" + } + local code, body = t.test('/apisix/admin/routes/1', + ngx.HTTP_PUT, + json.encode(data) + ) + + if code >= 300 then + ngx.status = code + ngx.say(body) + return + end + + local data = { + upstream = { + type = "roundrobin", + nodes = { + ["127.0.0.1:1980"] = 1, + }, + }, + uri = "/hello" + } + assert(t.test('/apisix/admin/routes/2', + ngx.HTTP_PUT, + json.encode(data) + )) + + local data = { + cert = ssl_cert, + key = ssl_key, + sni = "localhost", + client = { + ca = ssl_ca_cert, + depth = 2, + } + } + local code, body = t.test('/apisix/admin/ssl/1', + ngx.HTTP_PUT, + json.encode(data) + ) + + if code >= 300 then + ngx.status = code + end + ngx.print(body) + } + } +--- request +GET /t + + + +=== TEST 4: hit +--- request +GET /mtls +--- more_headers +Host: localhost +--- response_body +hello world + + + +=== TEST 5: no client certificate +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin") + local json = require("toolkit.json") + local ssl_ca_cert = t.read_file("t/certs/mtls_ca.crt") + local ssl_cert = t.read_file("t/certs/mtls_client.crt") + local ssl_key = t.read_file("t/certs/mtls_client.key") + local data = { + upstream = { + scheme = "https", + type = "roundrobin", + nodes = { + ["127.0.0.1:1994"] = 1, + }, + }, + plugins = { + ["proxy-rewrite"] = { + uri = "/hello" + } + }, + uri = "/mtls2" + } + local code, body = t.test('/apisix/admin/routes/1', + ngx.HTTP_PUT, + json.encode(data) + ) + + if code >= 300 then + ngx.status = code + ngx.say(body) + return + end + + ngx.print(body) + } + } +--- request +GET /t + + + +=== TEST 6: hit +--- request +GET /mtls2 +--- more_headers +Host: localhost +--- error_code: 400 +--- error_log +client certificate was not present + + + +=== TEST 7: wrong client certificate +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin") + local json = require("toolkit.json") + local ssl_ca_cert = t.read_file("t/certs/mtls_ca.crt") + local ssl_cert = t.read_file("t/certs/apisix.crt") + local ssl_key = t.read_file("t/certs/apisix.key") + local data = { + upstream = { + scheme = "https", + type = "roundrobin", + nodes = { + ["127.0.0.1:1994"] = 1, + }, + tls = { + client_cert = ssl_cert, + client_key = ssl_key, + } + }, + plugins = { + ["proxy-rewrite"] = { + uri = "/hello" + } + }, + uri = "/mtls3" + } + local code, body = t.test('/apisix/admin/routes/1', + ngx.HTTP_PUT, + json.encode(data) + ) + + if code >= 300 then + ngx.status = code + ngx.say(body) + return + end + + ngx.print(body) + } + } +--- request +GET /t + + + +=== TEST 8: hit +--- request +GET /mtls3 +--- more_headers +Host: localhost +--- error_code: 400 +--- error_log +clent certificate verification is not passed: FAILED:self signed certificate