From 87374dc61b9ca39f7c181931579f3abd1d4ac186 Mon Sep 17 00:00:00 2001 From: Yuansheng Date: Wed, 24 Jul 2019 11:50:13 +0800 Subject: [PATCH 1/2] change(key-auth): removed useless comments and used simple code. --- lua/apisix/plugins/key-auth.lua | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/lua/apisix/plugins/key-auth.lua b/lua/apisix/plugins/key-auth.lua index dd0820f636ab..78f631ffb5fe 100644 --- a/lua/apisix/plugins/key-auth.lua +++ b/lua/apisix/plugins/key-auth.lua @@ -3,9 +3,6 @@ local plugin_name = "key-auth" local ipairs = ipairs --- You can follow this document to write schema: --- https://github.com/Tencent/rapidjson/blob/master/bin/draft-04/schema --- rapidjson not supported `format` in draft-04 yet local schema = { type = "object", properties = { @@ -40,13 +37,7 @@ end -- do function _M.check_schema(conf) - local ok, err = core.schema.check(schema, conf) - - if not ok then - return false, err - end - - return true + return core.schema.check(schema, conf) end From 083322f677cb077740f629dd6f376b7f4c39d338 Mon Sep 17 00:00:00 2001 From: Yuansheng Date: Wed, 24 Jul 2019 16:59:37 +0800 Subject: [PATCH 2/2] feature: supported JWT plugin and added test cases. --- COPYRIGHT | 33 +++++ conf/config.yaml | 1 + doc/plugins/jwt-auth-cn.md | 153 +++++++++++++++++++++++ lua/apisix/admin/consumers.lua | 10 ++ lua/apisix/admin/init.lua | 2 +- lua/apisix/core/id.lua | 3 + lua/apisix/plugins/jwt-auth.lua | 187 ++++++++++++++++++++++++++++ rockspec/apisix-dev-0.rockspec | 2 + t/admin/plugins.t | 4 +- t/plugin/jwt-auth.t | 214 ++++++++++++++++++++++++++++++++ 10 files changed, 606 insertions(+), 3 deletions(-) create mode 100644 doc/plugins/jwt-auth-cn.md create mode 100644 lua/apisix/plugins/jwt-auth.lua create mode 100644 t/plugin/jwt-auth.t diff --git a/COPYRIGHT b/COPYRIGHT index 2c9b080dda38..98408cde0b6b 100644 --- a/COPYRIGHT +++ b/COPYRIGHT @@ -206,3 +206,36 @@ LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. %%%%%%%%% + +lua-resty-jwt + +https://github.com/cdbattags/lua-resty-jwt +https://github.com/cdbattags/lua-resty-jwt/blob/master/LICENSE + +https://github.com/SkyLothar/lua-resty-jwt +https://github.com/SkyLothar/lua-resty-jwt/blob/master/LICENSE + +Apache License 2 + +%%%%%%%%% + +lua-resty-cookie + +https://github.com/cloudflare/lua-resty-cookie + +This module is licensed under the BSD license. + +Copyright (C) 2013, by Jiale Zhi vipcalio@gmail.com, CloudFlare Inc. + +Copyright (C) 2013, by Yichun Zhang agentzh@gmail.com, CloudFlare Inc. + +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + diff --git a/conf/config.yaml b/conf/config.yaml index 67c961b6fd70..8821017a918e 100644 --- a/conf/config.yaml +++ b/conf/config.yaml @@ -23,3 +23,4 @@ plugins: # plugin list - prometheus - limit-conn - node-status + - jwt-auth diff --git a/doc/plugins/jwt-auth-cn.md b/doc/plugins/jwt-auth-cn.md new file mode 100644 index 000000000000..27289bf40a79 --- /dev/null +++ b/doc/plugins/jwt-auth-cn.md @@ -0,0 +1,153 @@ +[English](jwt-auth.md) + +# 目录 +- [**名字**](#名字) +- [**属性**](#属性) +- [**如何启用**](#如何启用) +- [**测试插件**](#测试插件) +- [**禁用插件**](#禁用插件) + + +## 名字 + +`jwt-auth` 是一个认证插件,它需要与 `consumer` 一起配合才能工作。 + +添加 JWT Authentication 到一个 `service` 或 `route`。 然后,`consumer` 将其密钥添加到查询字符串参数、请求头或 `cookie` 中以验证其请求。 + +有关 JWT 的更多信息,可移步 [JWT](https://jwt.io/) 查看更多信息。 + +## 属性 + +* `key`: 不同的 `consumer` 对象应有不同的值,它应当是唯一的。不同 consumer 使用了相同的 `key` ,将会出现请求匹配异常。 +* `secret`: 可选字段,加密秘钥。如果您未指定,后台将会自动帮您生成。 +* `algorithm`:可选字段,加密算法。目前支持 `HS256`, `HS384`, `HS512`, `RS256` 和 `ES256`,如果未指定,则默认使用 `HS256`。 +* `exp`: 可选字段,token 的超时时间,以秒为单位的计时。比如有效期是 5 分钟,那么就应设置为 `5 * 60 = 300`。 + +## 如何启用 + +1. 创建一个 consumer 对象,并设置插件 `jwt-auth` 的值。 + +```shell +curl http://127.0.0.1:9080/apisix/admin/consumers -X PUT -d ' +{ + "username": "jack", + "plugins": { + "jwt-auth": { + "key": "user-key", + "secret": "secret-key" + } + } +}' +``` + +2. 创建 route 或 service 对象,并开启 `jwt-auth` 插件。 + +```shell +curl http://127.0.0.1:9080/apisix/admin/routes/1 -X PUT -d ' +{ + "methods": ["GET"], + "uri": "/index.html", + "plugins": { + "jwt-auth": {} + }, + "upstream": { + "type": "roundrobin", + "nodes": { + "39.97.63.215:80": 1 + } + } +}' +``` + +## Test Plugin + +#### 首先进行登录获取 `jwt-auth` token: + +```shell +$ curl http://127.0.0.2:9080/apisix/plugin/jwt/sign?key=user-key -i +HTTP/1.1 200 OK +Date: Wed, 24 Jul 2019 10:33:31 GMT +Content-Type: text/plain +Transfer-Encoding: chunked +Connection: keep-alive +Server: APISIX web server + +eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiJ1c2VyLWtleSIsImV4cCI6MTU2NDA1MDgxMX0.Us8zh_4VjJXF-TmR5f8cif8mBU7SuefPlpxhH0jbPVI +``` + +#### 使用获取到的 token 进行请求尝试 + +* 缺少 token + +```shell +$ curl http://127.0.0.2:9080/index.html -i +HTTP/1.1 401 Unauthorized +... +{"message":"Missing JWT token in request"} +``` + +* token 放到请求头中: + +```shell +$ curl http://127.0.0.2:9080/index.html -H 'Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiJ1c2VyLWtleSIsImV4cCI6MTU2NDA1MDgxMX0.Us8zh_4VjJXF-TmR5f8cif8mBU7SuefPlpxhH0jbPVI' -i +HTTP/1.1 200 OK +Content-Type: text/html +Content-Length: 13175 +... +Accept-Ranges: bytes + + + +... +``` + +* token 放到请求参数中: + +```shell +$ curl http://127.0.0.2:9080/index.html?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiJ1c2VyLWtleSIsImV4cCI6MTU2NDA1MDgxMX0.Us8zh_4VjJXF-TmR5f8cif8mBU7SuefPlpxhH0jbPVI -i +HTTP/1.1 200 OK +Content-Type: text/html +Content-Length: 13175 +... +Accept-Ranges: bytes + + + +... +``` + +* token 放到 cookie 中: + +```shell +$ curl http://127.0.0.2:9080/index.html --cookie jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiJ1c2VyLWtleSIsImV4cCI6MTU2NDA1MDgxMX0.Us8zh_4VjJXF-TmR5f8cif8mBU7SuefPlpxhH0jbPVI -i +HTTP/1.1 200 OK +Content-Type: text/html +Content-Length: 13175 +... +Accept-Ranges: bytes + + + +... +``` + +## 禁用插件 + +当你想去掉 `jwt-auth` 插件的时候,很简单,在插件的配置中把对应的 `json` 配置删除即可,无须重启服务,即刻生效: + +```shell +$ curl http://127.0.0.1:2379/v2/keys/apisix/routes/1 -X PUT -d value=' +{ + "methods": ["GET"], + "uri": "/index.html", + "id": 1, + "plugins": { + }, + "upstream": { + "type": "roundrobin", + "nodes": { + "39.97.63.215:80": 1 + } + } +}' +``` diff --git a/lua/apisix/admin/consumers.lua b/lua/apisix/admin/consumers.lua index 871530ae897a..c2ec6d20d3ed 100644 --- a/lua/apisix/admin/consumers.lua +++ b/lua/apisix/admin/consumers.lua @@ -1,4 +1,5 @@ local core = require("apisix.core") +local plugins = require("apisix.admin.plugins") local _M = { version = 0.1, @@ -23,6 +24,15 @@ local function check_conf(consumer_name, conf) return nil, {error_msg = "invalid configuration: " .. err} end + if not conf.plugins then + return consumer_name + end + + ok, err = plugins.check_schema(conf.plugins) + if not ok then + return nil, {error_msg = "invalid configuration: " .. err} + end + return consumer_name end diff --git a/lua/apisix/admin/init.lua b/lua/apisix/admin/init.lua index f656ff79c612..1162791a4001 100644 --- a/lua/apisix/admin/init.lua +++ b/lua/apisix/admin/init.lua @@ -39,7 +39,7 @@ local function run(params) local data, err = core.json.decode(req_body) if not data then core.log.error("invalid request body: ", req_body, " err: ", err) - core.response.exit(401, {error_msg = "invalid request body", + core.response.exit(400, {error_msg = "invalid request body", req_body = req_body}) end diff --git a/lua/apisix/core/id.lua b/lua/apisix/core/id.lua index 270427336e0b..4c82e64e00ac 100644 --- a/lua/apisix/core/id.lua +++ b/lua/apisix/core/id.lua @@ -39,6 +39,9 @@ local function write_file(path, data) end +_M.gen_uuid_v4 = uuid.generate_v4 + + function _M.init() local uid_file_path = prefix .. "/conf/apisix.uid" apisix_uid = read_file(uid_file_path) diff --git a/lua/apisix/plugins/jwt-auth.lua b/lua/apisix/plugins/jwt-auth.lua new file mode 100644 index 000000000000..706e502d2265 --- /dev/null +++ b/lua/apisix/plugins/jwt-auth.lua @@ -0,0 +1,187 @@ +local core = require("apisix.core") +local jwt = require("resty.jwt") +local ck = require("resty.cookie") +local ipairs= ipairs +local ngx = ngx +local ngx_time = ngx.time +local plugin_name = "jwt-auth" + + +local schema = { + type = "object", + properties = { + key = {type = "string"}, + secret = {type = "string"}, + algorithm = { + type = "string", + enum = {"HS256", "HS384", "HS512", "RS256", "ES256"} + }, + exp = {type = "integer", minimum = 1}, + } +} + + +local _M = { + version = 0.1, + priority = 2510, + name = plugin_name, + schema = schema, +} + + +local create_consume_cache +do + local consumer_ids = {} + + function create_consume_cache(consumers) + core.table.clear(consumer_ids) + + for _, consumer in ipairs(consumers.nodes) do + consumer_ids[consumer.conf.key] = consumer + end + + return consumer_ids + end + +end -- do + + +function _M.check_schema(conf) + core.log.info("input conf: ", core.json.delay_encode(conf)) + + local ok, err = core.schema.check(schema, conf) + if not ok then + return false, err + end + + if not conf.secret then + conf.secret = core.id.gen_uuid_v4() + end + + if not conf.algorithm then + conf.algorithm = "HS256" + end + + if not conf.exp then + conf.exp = 60 * 60 * 24 + end + + return true +end + + +local function fetch_jwt_token() + local args = ngx.req.get_uri_args() + if args and args.jwt then + return args.jwt + end + + local headers = ngx.req.get_headers() + if headers.Authorization then + return headers.Authorization + end + + local cookie, err = ck:new() + if not cookie then + return nil, err + end + + local val, err = cookie:get("jwt") + return val, err +end + + +function _M.rewrite(conf, ctx) + local jwt_token, err = fetch_jwt_token() + if not jwt_token then + if err and err:sub(1, #"no cookie") ~= "no cookie" then + core.log.error("failed to fetch JWT token: ", err) + end + + return 401, {message = "Missing JWT token in request"} + end + + local jwt_obj = jwt:load_jwt(jwt_token) + core.log.info("jwt object: ", core.json.delay_encode(jwt_obj)) + if not jwt_obj.valid then + return 401, {message = jwt_obj.reason} + end + + local user_key = jwt_obj.payload and jwt_obj.payload.key + if not user_key then + return 401, {message = "missing user key in JWT token"} + end + + local consumer_conf = core.consumer.plugin(plugin_name) + local consumers = core.lrucache.plugin(plugin_name, "consumers_key", + consumer_conf.conf_version, + create_consume_cache, consumer_conf) + + local consumer = consumers[user_key] + if not consumer then + return 401, {message = "Invalid user key in JWT token"} + end + + jwt_obj = jwt:verify_jwt_obj(consumer.conf.secret, jwt_obj) + core.log.info("jwt object: ", core.json.delay_encode(jwt_obj)) + if not jwt_obj.verified then + return 401, {message = jwt_obj.reason} + end + + core.log.info("hit key-auth access") +end + + +local function gen_token() + local args = ngx.req.get_uri_args() + if not args or not args.key then + return core.response.exit(400) + end + + local key = args.key + + local consumer_conf = core.consumer.plugin(plugin_name) + if not consumer_conf then + return core.response.exit(404) + end + + local consumers = core.lrucache.plugin(plugin_name, "consumers_key", + consumer_conf.conf_version, + create_consume_cache, consumer_conf) + + core.log.info("consumers: ", core.json.delay_encode(consumers)) + local consumer = consumers[key] + if not consumer then + return core.response.exit(404) + end + + local jwt_token = jwt:sign( + consumer.conf.secret, + { + header={ + typ = "JWT", + alg = consumer.conf.algorithm + }, + payload={ + key = key, + exp = ngx_time() + consumer.conf.exp + } + } + ) + + core.response.exit(200, jwt_token) +end + + +function _M.api() + return { + { + methods = {"GET"}, + uri = "/apisix/plugin/jwt/sign", + handler = gen_token, + } + } +end + + +return _M diff --git a/rockspec/apisix-dev-0.rockspec b/rockspec/apisix-dev-0.rockspec index 38c70fbd8d29..df415b7bd206 100644 --- a/rockspec/apisix-dev-0.rockspec +++ b/rockspec/apisix-dev-0.rockspec @@ -23,6 +23,8 @@ dependencies = { "lua-resty-jit-uuid = 0.0.7", "rapidjson = 0.6.0-1", "lua-resty-healthcheck-iresty = 1.0.0", + "lua-resty-jwt = 0.2.0", + "lua-resty-cookie = 0.1.0", } build = { diff --git a/t/admin/plugins.t b/t/admin/plugins.t index 3c002528a1d2..158bba6ddb9f 100644 --- a/t/admin/plugins.t +++ b/t/admin/plugins.t @@ -13,7 +13,7 @@ __DATA__ === TEST 1: get plugins' name --- request GET /apisix/admin/plugins/list ---- response_body -["example-plugin","limit-req","limit-count","key-auth","prometheus","limit-conn","node-status"] +--- response_body_like eval +qr/example-plugin","limit-req","limit-count","key-auth","prometheus","limit-conn","node-status"/ --- no_error_log [error] diff --git a/t/plugin/jwt-auth.t b/t/plugin/jwt-auth.t new file mode 100644 index 000000000000..eaae47ad2313 --- /dev/null +++ b/t/plugin/jwt-auth.t @@ -0,0 +1,214 @@ +use t::APISix 'no_plan'; + +repeat_each(2); +no_long_string(); +no_root_location(); +no_shuffle(); +run_tests; + +__DATA__ + +=== TEST 1: sanity +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.jwt-auth") + local conf = { + + } + + local ok, err = plugin.check_schema(conf) + if not ok then + ngx.say(err) + end + + ngx.say(require("cjson").encode(conf)) + } + } +--- request +GET /t +--- response_body_like eval +qr/{"algorithm":"HS256","secret":"\w+-\w+-\w+-\w+-\w+","exp":86400}/ +--- no_error_log +[error] + + + +=== TEST 2: wrong type of string +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.jwt-auth") + local ok, err = plugin.check_schema({key = 123}) + if not ok then + ngx.say(err) + end + + ngx.say("done") + } + } +--- request +GET /t +--- response_body +invalid "type" in docuement at pointer "#/key" +done +--- no_error_log +[error] + + + +=== TEST 3: add consumer with username and plugins +--- 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": "user-key", + "secret": "my-secret-key" + } + } + }]], + [[{ + "node": { + "value": { + "username": "jack", + "plugins": { + "jwt-auth": { + "key": "user-key", + "secret": "my-secret-key" + } + } + } + }, + "action": "set" + }]] + ) + + ngx.status = code + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed +--- no_error_log +[error] + + + +=== TEST 4: 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:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/hello" + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed +--- no_error_log +[error] + + + +=== TEST 5: sign +--- request +GET /apisix/plugin/jwt/sign?key=user-key +--- response_body_like eval +qr/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.\w+.\w+/ +--- no_error_log +[error] + + + +=== TEST 6: verify, missing token +--- request +GET /hello +--- error_code: 401 +--- response_body +{"message":"Missing JWT token in request"} +--- no_error_log +[error] + + + +=== TEST 7: verify: invalid JWT token +--- request +GET /hello?jwt=invalid-eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiJ1c2VyLWtleSIsImV4cCI6MTU2Mzg3MDUwMX0.pPNVvh-TQsdDzorRwa-uuiLYiEBODscp9wv0cwD6c68 +--- error_code: 401 +--- response_body +{"message":"invalid header: invalid-eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"} +--- no_error_log +[error] + + + +=== TEST 8: verify: expired JWT token +--- request +GET /hello?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiJ1c2VyLWtleSIsImV4cCI6MTU2Mzg3MDUwMX0.pPNVvh-TQsdDzorRwa-uuiLYiEBODscp9wv0cwD6c68 +--- error_code: 401 +--- response_body +{"message":"'exp' claim expired at Tue, 23 Jul 2019 08:28:21 GMT"} +--- no_error_log +[error] + + + +=== TEST 9: verify (in argument) +--- request +GET /hello?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiJ1c2VyLWtleSIsImV4cCI6MTg3OTMxODU0MX0.fNtFJnNmJgzbiYmGB0Yjvm-l6A6M4jRV1l4mnVFSYjs +--- response_body +hello world +--- no_error_log +[error] + + + +=== TEST 10: verify (in header) +--- request +GET /hello +--- more_headers +Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiJ1c2VyLWtleSIsImV4cCI6MTg3OTMxODU0MX0.fNtFJnNmJgzbiYmGB0Yjvm-l6A6M4jRV1l4mnVFSYjs +--- response_body +hello world +--- no_error_log +[error] + + + +=== TEST 11: verify (in cookie) +--- request +GET /hello +--- more_headers +Cookie: jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiJ1c2VyLWtleSIsImV4cCI6MTg3OTMxODU0MX0.fNtFJnNmJgzbiYmGB0Yjvm-l6A6M4jRV1l4mnVFSYjs +--- response_body +hello world +--- no_error_log +[error]