需求
在客户现场部署了微服务技术架构的业务产品,需要挂相关的连接能够访问本司的内部资源,如WIKI。除了在网络安全层面限制之外,还需要部署一套应用层面的安全网关,保证只有登录微服务产品的用户才能访问在线文档。
整体流程
nginx:源系统的代理网关,这里也用于拦截特殊的请求链接,获取cookies中的jwt token信息;
客户端openresty:验证token是否合法、是否过期,若使用redis存储token信息,则需访问redis;验证无误则进行签名后转发,签名密钥使用rsa pkcs1格式,以pem文件存储;
服务端openresty:验证请求是否合法,合法则进行转发,其中合法包括首次跳转合法和后续访问合法。
目标服务:请求全部由服务端openresty转发,服务应该验证白名单,在外部网段,只允许openresty访问。
技术框架
nginx、openresty、lua、jwt
关键技术点
获取token并转发
源微服务系统网关需要拦截这些特殊菜单请求, /online_wiki。并重定向到安全网关客户端。
upstream gateway_clients{
server 127.0.0.1:7777;
}
location = /online_wiki{
set $token '';
if ($http_cookie ~* "Admin-Token=([^;]+)(?:;|$)") {
set $token $1;
}
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://gateway_clients/jwt_lua?token=$token;
}
客户端openresty脚本核心逻辑
-
解密jwt token
-
访问redis,验证是否过期
-
签名(结合timestamp和nonce实现防重放攻击)
local function load_config(config_file)
local config = {}
local file = io.open(config_file, "r")
if not file then
ngx.log(ngx.ERR, "Unable to open config file: ", config_file)
return nil
end
for line in file:lines() do
local key, value = line:match("^(%S+)=(%S+)$")
if key and value then
config[key] = value
ngx.log(ngx.ERR,'key:value',key..value);
end
end
file:close()
return config
end
local function get_config_value(key)
local config = ngx.shared.nonce_cache:get("config")
if not config then
local debug = require("debug")
local info = debug.getinfo(2, "S")
local path = info.source:sub(2):match("(.*/)");
config = load_config(path..'config.conf')
ngx.shared.nonce_cache:set("config", config)
end
return config[key]
end
local cjson = require("cjson")
local jwt = require("resty.jwt")
local jwt_token = string.sub(ngx.req.get_uri_args()['token'],1);
ngx.log(ngx.ERR,"token,",jwt_token);
local jwt_obj = jwt:verify(get_config_value('token.pwd'), jwt_token)
if jwt_obj.verified == false then
ngx.log(ngx.WARN, "Invalid token: ".. jwt_obj.reason)
ngx.status = ngx.HTTP_UNAUTHORIZED
ngx.header.content_type = "application/json; charset=utf-8"
-- ngx.say(cjson.encode(jwt_obj))
ngx.exit(ngx.HTTP_UNAUTHORIZED)
end
ngx.log(ngx.ERR,"md5,",ngx.md5(jwt_token))
local redis = require "resty.redis"
local red = redis:new()
red:set_timeout(1000) -- 1 second timeout
local ok, err = red:connect(get_config_value('redis.ip'), get_config_value('redis.port'))
if not ok then
ngx.say("failed to connect: ", err)
ngx.exit(ngx.HTTP_UNAUTHORIZED)
red:close();
end
local ok, err = red:auth(get_config_value('redis.pwd'));
if not ok then
ngx.say("failed to auth: ", err)
ngx.exit(ngx.HTTP_UNAUTHORIZED)
red:close();
end
local ok,err = red:select(get_config_value('redis.db'));
if not ok then
ngx.say("failed to select: ", err)
ngx.exit(ngx.HTTP_UNAUTHORIZED)
red:close();
end
local res, err = red:get(string.format('uas:user:login:%s',ngx.md5(jwt_token)))
if not res then
ngx.say("failed to get dog: ", err)
ngx.exit(ngx.HTTP_UNAUTHORIZED)
end
if res == ngx.null then
ngx.say("dog not found.",res)
ngx.exit(ngx.HTTP_UNAUTHORIZED)
end
local ok,err = red:set_keepalive(30000,20);
if not ok then
ngx.log(ngx.ERR,"failed to set keepalive:",err);
end
local ppath = get_config_value('remotegateway.keypath');
local pname = get_config_value('remotegateway.keyname');
local file, err = io.open(ppath, "r")
if not file then
ngx.log(ngx.ERR,"无法打开文件:",err);
return
end
local private_key_path =file:read("*a")
-- 关闭文件
file:close()
ngx.log(ngx.ERR,"key:",private_key_path);
local b64 = require "ngx.base64"
local pk, err = require("resty.openssl.pkey").new(private_key_path)
if not pk then
ngx.log(ngx.ERR, '[ERROR]:', err)
return
end
local parameters, err = pk:get_parameters()
local e = parameters.e
local ts = os.time();
local uuid = require "resty.jit-uuid"
local my_uuid = uuid()
local data = ts..':'..my_uuid..':'..pname;
local digest, err = require("resty.openssl.digest").new("SHA256")
digest:update(data)
local signature, err = pk:sign(digest)
local wholetable = {c=pname,timestamp=ts,nonce=my_uuid,nsign=b64.encode_base64url(signature)}
--ngx.log(ngx.ERR,"数字签名参数,",ngx.escape_uri(cjson.encode(wholetable)));
ngx.redirect(get_config_value('remotegateway.url')..'/verify_lua?all='..ngx.escape_uri(cjson.encode(wholetable)),ngx.HTTP_MOVED_TEMPORARILY);
服务端openresty脚本核心逻辑
-
验签
-
保存链接,为后续验证_Referer_头准备
local function load_config(config_file)
local config = {}
local file = io.open(config_file, "r")
if not file then
ngx.log(ngx.ERR, "Unable to open config file: ", config_file)
return nil
end
for line in file:lines() do
local key, value = line:match("^(%S+)=(%S+)$")
if key and value then
config[key] = value
end
end
file:close()
return config
end
local function get_config_value(key)
local config = ngx.shared.nonce_cache:get("config")
if not config then
local debug = require("debug")
local info = debug.getinfo(2, "S")
local path = info.source:sub(2):match("(.*/)");
config = load_config(path..'config.conf')
ngx.shared.nonce_cache:set("config", config)
end
return config[key]
end
local cjson = require("cjson")
local b64 = require "ngx.base64"
local args = ngx.req.get_uri_args();
local all = args["all"];
local o = ngx.unescape_uri(all);
ngx.log(ngx.ERR,"接收数字签名,",o)
local args = cjson.decode(o);
local c = args["c"];
local ts = args["timestamp"];
local nonce = args["nonce"];
local nsign = args["nsign"];
local ppath = get_config_value('localgateway.keypath');
local file, err = io.open(ppath..c..'.pem', "r")
if not file then
ngx.log(ngx.ERR,"无法打开文件:",err);
return
end
local private_key_path =file:read("*a")
-- 关闭文件
file:close()
local pk, err = require("resty.openssl.pkey").new(private_key_path)
if not pk then
ngx.log(ngx.ERR, '[ERROR]:', err)
return
end
local data = ts..':'..nonce..':'..c;
ngx.log(ngx.ERR, 'sign-o',private_key_path)
local digest, err = require("resty.openssl.digest").new("SHA256")
digest:update(data)
local ok, err = pk:verify(b64.decode_base64url(nsign),digest)
if not ok then
ngx.log(ngx.ERR, '验签失败',err)
ngx.exit(ngx.HTTP_UNAUTHORIZED)
end
--check timestamp nonce
--ngx.log(ngx.ERR, '验签成功')
local at = tonumber(ts);
local ct = os.time();
if ct - at > 5 then
ngx.log(ngx.ERR, '时间差太长')
ngx.exit(ngx.HTTP_UNAUTHORIZED)
end
--ngx.log(ngx.ERR, '时间合法,ct=',ct,',at=',at);
local red = ngx.shared.nonce_cache;
local res = red:get(string.format('wiki:lua:login:%s',ngx.md5(c)))
if not res then
red:set(string.format('wiki:lua:login:%s',ngx.md5(c)),'1', 5);
--jump
local scheme = ngx.var.scheme
local host = ngx.var.host
local port = ngx.var.server_port
local request_uri = ngx.var.request_uri
local wholeurl = ''
--ngx.log(ngx.ERR,'端口号',port);
if (scheme == "http" and port == '80') or (scheme == "https" and port == '443') then
wholeurl = scheme .. "://" .. host .. request_uri
else
wholeurl = scheme .. "://" .. host .. ":" .. port .. request_uri
end
red:set('wiki:refered:'..wholeurl,'1', 300);
--ngx.log(ngx.ERR,'设置地址','wiki:refered:'..wholeurl);
return
end
ngx.log(ngx.ERR,"重复的请求",res);
ngx.exit(ngx.HTTP_UNAUTHORIZED);
会话实现
在nginx中代理所有请求到后端
check.lua 检查referer并刷新缓存(续命)如下
local headers = ngx.req.get_headers()
local referer = headers["Referer"] or headers["referer"]
if referer then
-- ngx.log(ngx.ERR,"Referer header: ", referer)
else
ngx.log(ngx.ERR,"Referer header does not exist.")
ngx.exit(ngx.HTTP_UNAUTHORIZED);
end
local red = ngx.shared.nonce_cache;
--ngx.log(ngx.ERR,'获取值,','wiki:refered:'..referer);
local res = red:get('wiki:refered:'..referer)
if not res then
ngx.log(ngx.ERR,"非法的请求");
ngx.exit(ngx.HTTP_UNAUTHORIZED);
return;
end
-- ngx.log(ngx.ERR,"合法请求",res);
red:set('wiki:refered:'..referer,'1', 300);
在线安装
OpenResty - Download下载最新包,对于非最新的linux而言,编译openresty时,需要下载openssl源码包,在编译时指定
如下
./configure --prefix=/home/user/app/openresty --with-openssl=/home/user/app/openssl-1.1.1k
make && make install
进入openresty/bin目录,下载相关依赖的lua、openssl、jwt、uuid,分别进行加解密、token处理、uuid生成。
opm get fffonion/lua-resty-openssl
opm get SkyLothar/lua-resty-jwt
opm get openresty/lua-resty-jit-uuid