openresty+redis拦截高频访问IP

CC攻击

网站受到攻击通常是黑客通过几个甚至多个IP地址,在短时间内进行超高频率访问,从而让服务器在短时间内增加巨大的计算量,导致负载增加,降低响应能力,甚至直接宕机停止服务。
通常这类情况我们只能通过查看分析网站日志,从而获得攻击者的IP地址,再通过防火墙进行拦截。
但一般而言这只会发生在监控系统已经开始报警之后,也就是网站或服务已经遭受到了攻击,并造成影响之后。并且在日志中搜寻到攻击者的IP并不是十分简单的事情,通常当我们找到了攻击者,攻击者可能已经停止了攻击,我们也只能预防他下次可能的攻击。

自动拦截

经历了黑客们深夜的骚扰和攻击,如何让那些短时间内大量访问的地址被自动拦截变成了努力的方向。
云服务商提供了WAF等商业化产品,协助我们处理这些威胁。
相比较于这些高昂价格的产品,开源软件同样在灵活性和可整合性上有很大的优势,接下来就介绍一下我是如何使用openresty和redis实现拦截高频访问的地址。

安装环境

之前的文章已经介绍过:Openresty+Redis 动态切换upstream (http://learn-learn.top/archives/169.html)
大致按照官方介绍就可以轻松安装。

nginx配置

nginx在初始化时建立一个redis的链接,并且在每次访问前需要执行block.lua进行验证

init_by_lua_block {
    redis = require "redis"
    client = redis.connect('127.0.0.1', 6379)
}
server {
    listen 8080;
    location  / {
        access_by_lua_file /usr/local/nginx/conf/lua/block.lua; 
        proxy_pass http://192.168.1.102:8000;
    }
}

lua脚本:

function isConnected()
    return client:ping()
end
function createRedisConnection()
        return redis.connect('127.0.0.1', 6379)
end

if pcall(isConnected)then --如果发生redis连接失败,将停止拦截。
    --
else
    if pcall(createRedisConnection)then     --断开重连会发送每次访问都需要重连redis
        client = createRedisConnection();       --如果访问量大的情况下,建议关闭重连,if pcall不执行,直接ngx.exit
    else
        ngx.exit(ngx.OK);
    end 
end


local ttl = 60;     --监测周期
local bktimes = 30; --在监测周期内达到触发拦截的访问量
block_ttl = 600;    --触发拦截后拦截时间
ip = ngx.var.remote_addr
ipvtimes = client:get(ip)

if(ipvtimes)then
    if(ipvtimes == "-1")then
        --ngx.say("blocked")
        return ngx.exit(403);
    else
        last_ttl = client:ttl(ip)
        --ngx.say("key exist.ttl is ",last_ttl);
        if(last_ttl==-1)then
            client:set(ip,0)
            client:expire(ip,ttl)
            --ngx.say("ttl & vtimes recount")
            return ngx.exit(ngx.OK);
        end
        vtimes = tonumber(client:get(ip))+1;
        if(vtimes<bktimes)then
            client:set(ip,vtimes);
            client:expire(ip,last_ttl)
            --ngx.say(ip," view ",vtimes," times");
            return ngx.exit(ngx.OK);
        else
            --ngx.say(ip," will be block noext time.")
            client:set(ip,-1);
            client:expire(ip,block_ttl)
            return ngx.exit(ngx.OK);
        end
    end
else
    --ngx.say("key do not exist")
    client:set(ip,1)
    --ngx.say(ip," view 1 times")
    client:expire(ip,ttl)
    return ngx.exit(ngx.OK)
end

脚本说明:

1.重要参数:

ttl = 60; –监测周期
bktimes = 30; –在监测周期内达到触发拦截的访问量
block_ttl = 600; –触发拦截后拦截时间

以上参数表示,一个IP地址在60秒内访问超过30次将被拦截600秒。

2.逻辑说明:

a)检测初始化的redis连接是否能够正常运行,如果连接失败或已经断开,将会重新建立连接,如果仍旧无法连接,将直接放行。这里是为了避免redis宕机导致nginx无法正常响应。当然如果初始连接中断,将会导致每次访问都会创建redis连接。

b)当某个IP首次访问时,将在redis中新建一个以IP地址为KEY的键(如果需要多个站点,修改下key的命名规则即可),value为1,并设置expire时间。当这个地址再次访问且key尚未过期前,将会每次递增key的value数,直到到达达到bktimes,或者key到期从而消亡。(本人用的redis5.0,到期key会直接不存在,可能部分版本到期后value为-1)

c)当key过期后,对于系统而言就是第一次访问,重新创建value为1的新key

d)当达到bktimes后,会将对应的IP的key的value设置为-1,且过期时间为block_ttl。

e)当访问到value为-1的key,即某个IP达到了我们设定的访问频次,我们将直接拦截,返回403.

3.完善方向:

a)在访问前添加黑白名单功能(这个在redis中新建立两个key即可)
b)拦截IP段(根据访问的IP地址建立以IP段为key的字段即可)
c)redis断开重连后,每次访问都要建立连接问题。

Openresty的同步输出与流式响应

默认情况下, ngx.say和ngx.print都是异步输出的,先来看一个例子:

location /test {
    content_by_lua_block {
        ngx.say("hello")
        ngx.sleep(3)
        ngx.say("the world")
    }
}

执行测试,可以发现首先, /test 响应内容是在触发请求 3s 后一起接收到响应体,第一个ngx.say好像是被“绕过”,先执行sleep,然后和最后一个ngx.say的内容一起输出。

location /test {
    content_by_lua_block {
        ngx.say("hello")
        ngx.flush() -- 显式的向客户端刷新响应输出
        ngx.sleep(3)
        ngx.say("the world")
    }
}

首先输出”hello”,然后停顿3秒,最后输出”the world”——正如我们想象的那样。ngx.flush执行显示的输出,前一个ngx.say被“阻塞”住,执行完输出后方往下执行。

再看一个例子:

server {
    listen 80;
    lua_code_cache off;
    location /test {
        content_by_lua_block {
            ngx.say(string.rep("hello", 4000))
            ngx.sleep(3)
            ngx.say("the world")
        }
    }
}

这个例子和第一个例子相比,唯一不同就是ngx.say输出内容长了不少,我们发现浏览器先收到所有的hello,接着又收到了”the world” 。然而如果我们把4000改为小一点的值如2000(不同配置这个相对大小或有不同),那么仍然会出现先停顿3s,然后所有”hello”连同最后”the world”一起输出的情况。

通过以上三个例子,我们可以得出下面的结论:

ngx.say和ngx.print的同步和异步

  • nginx有个输出缓冲(system send buffer),如16k。ngx.say和ngx.print默认是向这个输出缓冲写入数据,如果没有显示的调用ngx.flush,那么在content阶段结束后输出缓冲会写入客户端;

  • 如果没有ngx.flush也没有到结束阶段,但如果输出缓冲区满了,那么也会输出到客户端;

因此ngx.say和ngx.print的默认向客户端的输出都是异步的,非实时性的,改变这一行为的是ngx.flush,可以做到同步和实时输出。这在流式输出,比如下载大文件时非常有用。

ngx.flush的同步和异步

lua-nginx也提到了ngx.flush的同步和异步。某一个ngx.say或者ngx.print调用后,这部分输出内容会写到输出缓冲区,同步的方式ngx.flush(true)会等到内容全部写到缓冲区再输出到客户端,而异步的方式ngx.flush()会将内容一边写到缓冲区,而缓冲区则一边将这些内容输出到客户端。

openresty和nginx流式输出的比较

流式输出,或者大文件的下载,nginx的upstream模块已经做得非常好,可以通过proxy_buffering|proxy_buffer_size|proxy_buffers 等指令精细调控,而且这些指令的默认值已经做了妥善处理。我们来看看这些指令以及默认值:

proxy_buffering on;
proxy_buffer_size 4k|8k; 
proxy_buffers 8 4k|8k; 
proxy_busy_buffers_size 8k|16k;
proxy_temp_path proxy_temp;
  • proxy_buffering on表示内存做整体缓冲,内存不够时多余的存在由proxy_temp_path指定的临时文件中,off表示每次从上游接收proxy_buffer_size响应的内容然后直接输出给客户端,不会试图缓冲整个响应
  • proxy_buffer_size和proxy_buffers都是指定内存缓冲区的大小,proxy_buffer_size通常缓冲响应头,proxy_buffers缓冲响应内容,默认为一页的大小,proxy_buffers还可以指定这样的缓冲区的个数
  • proxy_busy_buffers_size nginx在试图缓冲整个响应过程中,可以让缓冲区proxy_busy_buffers_size大小的已经写满的部分先行发送给客户端。于此同时,缓冲区的另外部分可以继续读。如果内存缓冲区不够用了,还可以写在文件缓冲区
  • proxy_temp_path 使用文件作为接受上游请求的缓冲区buffer,当内存缓冲区不够用时启用

openresty的怎么做到过大响应的输出呢? (https://moonbingbing.gitbooks.io/openresty-best-practices/content/index.html) 提到了两种情况:

  • 输出内容本身体积很大,例如超过 2G 的文件下载
  • 输出内容本身是由各种碎片拼凑的,碎片数量庞大

前面一种情况非常常见,后面一种情况比如上游已经开启Chunked的传输方式,而且每片chunk非常小。笔者就遇到了一个上游服务器通过Chunked分片传输日志,而为了节省上游服务器的内存将每片设置为一行日志,一般也就几百字节,这就太“碎片”了,一般日志总在几十到几百M,这么算下来chunk数量多大10w+。笔者用了resty.http来实现文件的下载,文件总大小48M左右。

local http = require "resty.http"
local httpc = http.new()

httpc:set_timeout(6000)
httpc:connect(host, port)

local client_body_reader, err = httpc:get_client_body_reader()

local res, err = httpc:request({
    version = 1.1,
    method = ngx.var.request_method,
    path = ngx.var.app_uri,
    headers = headers,
    query = ngx.var.args,
    body = client_body_reader
})

if not res then
    ngx.say("Failed to request ".. ngx.var.app_name .." server: ", err)
    return
end

-- Response status
ngx.status = res.status

-- Response headers
for k, v in pairs(res.headers) do
    if k ~= "Transfer-Encoding" then  --必须删除上游Transfer-Encoding响应头
        ngx.header[k] = v
    end
end

-- Response body
local reader = res.body_reader
repeat
    local chunk, err = reader(8192)
    if err then
        ngx.log(ngx.ERR, err)
        break
    end

    if chunk then
        ngx.print(chunk)
        ngx.flush(true)  -- 开启ngx.flush,实时输出
    end
until not chunk

local ok, err = httpc:set_keepalive()
if not ok then
    ngx.say("Failed to set keepalive: ", err)
    return
end

多达10w+的”碎片”的频繁的调用ngx.pirnt()和ngx.flush(true),使得CPU不堪重负,出现了以下的问题:

  • CPU轻轻松松冲到到100%,并保持在80%以上
  • 由于CPU的高负荷,实际的下载速率受到显著的影响
  • 并发下载及其缓慢。笔者开启到第三个下载连接时基本就没有反应了

未分类

这是开启了ngx.flush(true)的情况(ngx.flush()时差别不大),如果不开启flush同步模式,则情况会更糟糕。CPU几乎一直维持在100%左右:

未分类

可见,在碎片极多的流式传输上,以上官方所推荐的openresty使用方法效果也不佳。

于是,回到nginx的upstream模块,改content_by_lua_file为proxy_pass再做测试,典型的资源使用情况为:

未分类

无论是CPU还是内存占用都非常低,开启多个下载链接后并无显著提升,偶尔串升到30%但迅速下降到不超过10%。

因此结论是,涉及到大输出或者碎片化响应的情况,最好还是采用nginx自带的upstream方式,简单方便,精确控制。而openresty提供的几种方式,无论是异步的ngx.say/ngx.print还是同步的ngx.flush,实现效果都不理想。

用openresty做后台服务器

【腾讯云】校园优惠再度来袭cloud.tencent.com

与OpenResty及其原作者邂逅

OpenResty是一个优秀的开源项目,作者是章亦春。官网是openresty.org/en/。这已经是我第二次在公司项目中使用它展开业务了。分享使用经历的时候,顺便帮春哥推广一下:

未分类

其实,我想说的是春哥真的像知乎上传的一样,热情,专业。同时也希望大家都到邮件列表里问问题,像我一样莽莽撞撞直接私发邮件,确实显得不大矜持,汗!

我们后台选用的第三方库和文件

我们后台采用: openresty框架+lua脚本+c函数+mongoDB数据库.这其中的技术细节我们都论证过,现把他们都链接粘出来。

  1. Lua调用mongo的驱动 github.com/bigplum/lua…
  2. OpenResty官网下载 openresty.org/en/download…
  3. Lua调用C函数blog.csdn.net/wuxiangege/…

架构思想和坑

想成为高手,得首先学会跟高手一样思考问题,我觉得这也是每一个有志向的程序员练级所必须经历的。下面是我跟架构师的工作交流时,从正面和侧面所总结的,即为什么要采用:openresty框架+lua脚本+c函数+mongoDB数据库作为后台的原因。

  • 对于openresty,我的理解是openresty = web服务器nginx + lua虚拟机。它的好处官网都有,如”能够处理超高并发、扩展性极高的动态 Web 应用、Web 服务和动态网关”
    。对于我们的项目,架构师看中的似乎是openresty对nginx的lua扩展,因为接下来我们会调用第三方提供的dll文件,即lua调C。在操作数据库方面,openresty提供的很多精良的数据库驱动,如memcached、mysql、redis。虽然官方没有提供mongodb,但bigplum大神已经做到了,见上方链接。经过测试,bigplum大神提供的测试文件可用,对文档的数据库、集合和文档的增删改查均可用,点个赞。
  • openresty官方已经实现了redis的驱动,我们为什么选用mongodb而不是redis?架构师是这么回答我的,1、redis太重了,mongodb稍微轻一点点,而且我们的项目是在win上部署的对内存有一些要求,架构师希望它在普通的pc上也能运行;2、项目要求数据库同步,也就是a/b/c/d/四台机器数据保持数据的一致性。在这一点上,我们采取的是mongodb的副本集。

讨论太廉价了,直接上代码吧

nginx.conf文件

worker_processes  1;

events {
    worker_connections  1024;
}


http {
    include       mime.types;
    default_type  application/octet-stream;
    lua_package_path '/c/Users/wuxian/Desktop/BlackHole/openresty-1.9.15.1-win32/lualib/resty/mongol/init.lua;;';


    sendfile        on;
    keepalive_timeout  65;

    server {
        listen       80;
        server_name  localhost;

        location / {
            #解决跨域问题
            if ($request_method = 'OPTIONS') {
                add_header 'Access-Control-Allow-Origin' '*';
                add_header 'Access-Control-Allow-Headers' 'Content-Type';
                return 204;
            }   
            if ($request_method = 'POST') {
                add_header 'Access-Control-Allow-Origin' '*';
            }   

            #处理业务的文件
            default_type text/html;
            content_by_lua_file './lua_script/app.lua'; 
        }

        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   html;
        }
    }
}

app.lua文件

local mongo = require "resty.mongol"
local cjson = require "cjson.safe"

-----------------------------------------------------------------------------
--lua call c module
square = package.loadlib("./ADD.dll", "isquare")
alert  = package.loadlib("./ADD.dll", "alert")
add    = package.loadlib("./ADD.dll", "l_add")
sub    = package.loadlib("./ADD.dll", "l_sub")

-----------------------------------------------------------------------------
--Connect mongodb database
conn = mongo:new()

ok, err = conn:set_timeout(5000) 
ok, err = conn:connect("127.0.0.1", 27017)
if not ok then
    ngx.say("connect failed: "..err)
end

local db = conn:new_db_handle("tamigroup")
if not db then
    ngx.say(db)
end

local col = db:get_col("number")

-----------------------------------------------------------------------------
-- Process request data
local function post_request(data)
    local result = {}
    local request = cjson.decode(data)
    if not request or not request["cmd"] then
        result["result"] = "failed"
        result["reason"] = "no 'cmd' field or not a json struct"
        return result
    end

    local sql_str = ""

    --测试
    if request["cmd"] == "demo" then
        local result = {}
        result["result"] = "ok"
        result["reason"] = "This is a test cmd"
        return result
    end

    --插入人员
    if request["cmd"] == "add" then
        local result = {}

        local t = {}
        table.insert(t, {name=request["name"], id=request["id"]})
        r, err = col:insert(t, nil, true)
        if not r then 
            result["result"] = "insert failed: "..err
        else
            result["result"] = "ok"
        end
        return result
    end

    --删除人员
    if request["cmd"] == "delete" then
        local result = {}

        r, err = col:delete({name=request["name"]}, nil, true)
        if not r then 
            request["result"] = "delete failed: "..err
        else
            result["request"] = "ok" 
        end
        return result
    end

    --修改人员
    if request["cmd"] == "update" then
        local result = {}

        r,err = col:update({name=request["old"]},{name=request["new"]}, nil, nil, true)
        if not r then 
            request[result] = "update failed: "..err
        else
            request[result] = "ok"
        end
        return result
    end

    --查询人员
    if request["cmd"] == "find" then
        local result = {}

        r = col:find({name=result["name"]})
        r:limit(1)
        for i , v in r:pairs() do
            result["data"] = v["name"];
        end

        if not result["data"] then
            result["result"] = "He or she don't exist"
        else
            result["result"] = "ok"
        end
        return result
    end

    --调用c模块
    if request["cmd"] == "lua_call_c" then
        local result = {}
        result["data"] = square(12)
        result["add"] = add(100, 50)
        result["result"] = "ok"
        return result
    end

end

-----------------------------------------------------------------------------
-- Parse request body
ngx.req.read_body()
local method = ngx.var.request_method
if method ~= "POST" then
    local result = {}
    result["result"] = "failed"
    result["reason"] = "use POST method"
    ngx.say(cjson.encode(result))
else
    local data = ngx.req.get_body_data()
    ngx.say(cjson.encode(post_request(data)))
end

后记

整个项目的大致思路就是这酱紫的。在这里面,我们曾调研lua在win下怎样调用c;openresty怎样驱动mongodb;mongodb搭建集群数据怎么保持一致…….也傻乎乎的给春哥和云风写过邮件等等。最终前期调研终于顺利完成,累。

openresty实现图片(文件)服务器

介绍

前序

该功能是利用openresty的lua脚本实现的图片(文件)保存功能,文件上传使用java代码开发的

数据定义

上传数据和文件信息不分前后,但系统只会保存最后一对信息

  • 数据格式:
{"fileDir":"文件保存的目录","fileName":"文件名"}
  • 返回结果
{"status":"是否成功","result":"返回结果","msg":"异常原因"}
enum status:["success","failed"]
  • 保存文件夹
    所保存到那个文件夹下,在nginx的perfix变量中定义

代码实现

Nginx配置

如下:

server {
    listen       80;
    server_name  localhost;
# 配置保存的文件夹
    set $prefix "/data";

    location /uploadimage {
# 配置是否每次lua更改都生效,适合调试时使用
#       lua_code_cache off;
# 配置lua脚本
        content_by_lua_file /openresty-web/luascript/luascript;
    }
# 用来配合理解传入到nginx的报文结构
    location /uploadtest{
#       lua_code_cache off;
        content_by_lua_file /openresty-web/luascript/luauploadtest;
    }
    error_page   500 502 503 504  /50x.html;
    location = /50x.html {
        root   html;
    }
}

lua脚本

luascript:

package.path = '/openresty-web/lualib/resty/?.lua;'
local upload = require "upload"
local cjson = require("cjson")

Result={status="success",result="",msg=""}
Result.__index=Result
function Result.conSuccess(ret)
    ret["status"]="success"
    ret["result"]="upload success"
    return ret
end

function Result.conFailed(ret,err)
    ret["status"]="failed"
    ret["msg"]=err
    ret["result"]="upload failed"
    return ret
end

function Result:new()
    local ret={}
    setmetatable({},Result)
    return ret
end

-- lua-resty-upload
local chunk_size = 4096
local form = upload:new(chunk_size)
if not form then
    ngx.say(cjson.encode(Result.conFailed(Result:new(),"plase upload right info")))
    return 
end
local file
local filelen=0
form:set_timeout(0) -- 1 sec
local filename
local prefix=ngx.var.prefix

-- 匹配文件名,当前案例用于判断是否是文件模块
function get_filename(res)
    local filename = ngx.re.match(res,'(.+)filename="(.+)"(.*)')
    if filename then 
        return filename[2]
    end
end


-- 用来开启输入流,当文件夹不存在时自动创建
function openstream(fileinfo,opt)
    local file,err=io.open(prefix..fileinfo["fileDir"],"r")
    if not file then
        local start=string.find(err,"No such file or directory")
        if start then
            local exeret=os.execute("mkdir -p "..prefix..fileinfo["fileDir"])
            if exeret ~= 0 then
                return nil,"Make directory failed"
            end
        else
            return nil,err
        end
    end
    file,err=io.open(prefix..fileinfo["fileDir"]..fileinfo["fileName"],opt)
    return file,err
end

local osfilepath
local tmpfiletbl
local hasFile=false
local loopfile=false
local fileinfostr
local fileinfo
local result=Result:new()
-- 循环读取文件和文件信息
while true do
    local typ, res, err = form:read()
    if not typ then
        break
    end
    if typ == "header" then
        if res[1] ~= "Content-Type" then
            filename = get_filename(res[2])
            if filename then
                loopfile=true
                hasFile=true
                -- 判断是否有文件信息
                -- 如果没有记录内存
                if fileinfo then
                    file,err=openstream(fileinfo,"w")
                    if not file then
                        break
                    end
                else
                    tmpfiletbl={}
                end
            else
                loopfile = false
                fileinfostr = ""
            end
        end
    end
    if loopfile then
        if typ == "body" then
            if file then
                filelen= filelen + tonumber(string.len(res))    
                file:write(res)
            else
                table.insert(tmpfiletbl,res)
            end
        elseif typ == "part_end" then
            if file then
                file:close()
                file = nil
            end
        end
    else
        if typ == "body" then
            fileinfostr=fileinfostr .. res
        elseif typ == "part_end" then
            fileinfo = cjson.decode(fileinfostr)
        end
    end
    if typ == "eof" then
        break
    end
end

if not hasFile then
    err="plase upload file"
elseif not fileinfo or not fileinfo["fileDir"] or not fileinfo["fileName"] then
    err="plase offer file info"
end

if err then
    ngx.log(ngx.ERR,err)
    Result.conFailed(result,err)
    ngx.say(cjson.encode(result))
    return 
end

-- 因为有文件信息在文件之后传送的
-- 所以需要将输入到内存中的文件信息打印到磁盘
if tmpfiletbl and table.getn(tmpfiletbl) > 0 then
    file,err=openstream(fileinfo,"w")
    if not file then
        ngx.log(ngx.ERR,err)
        Result.conFailed(result,err)
        ngx.say(cjson.encode(result))
        return 
    else
        for index,value in ipairs(tmpfiletbl)
        do
            filelen= filelen + tonumber(string.len(value)) 
            file:write(value)
        end
        file:close()
        file=nil
    end
end


Result.conSuccess(result)
ngx.say(cjson.encode(result))

luauploadtest:

local upload = require "resty.upload"
local cjson = require "cjson"

local chunk_size = 5 -- should be set to 4096 or 8192
                     -- for real-world settings

local form, err = upload:new(chunk_size)
if not form then
    ngx.log(ngx.ERR, "failed to new upload: ", err)
    ngx.exit(500)
end

form:set_timeout(1000) -- 1 sec

while true do
    local typ, res, err = form:read()
    if not typ then
        ngx.say("failed to read: ", err)
        return
    end

    ngx.say("read: ", cjson.encode({typ, res}))

    if typ == "eof" then
        break
    end
end

local typ, res, err = form:read()
ngx.say("read: ", cjson.encode({typ, res}))

luauploadtest代码是官方提供代码

Java

ImageServer:

package cn.com.cgbchina.image;

import cn.com.cgbchina.image.exception.ImageDeleteException;
import cn.com.cgbchina.image.exception.ImageUploadException;
import org.springframework.web.multipart.MultipartFile;

/**
 * Created by 11140721050130 on 16-3-22.
 */
public interface ImageServer {
    /**
     * 刪除文件
     *
     * @param fileName 文件名
     * @return 是否刪除成功
     */
    boolean delete(String fileName) throws ImageDeleteException;

    /**
     *
     * @param originalName 原始文件名
     * @param file 文件
     * @return 文件上传后的相对路径
     */
    String upload(String originalName, MultipartFile file) throws ImageUploadException;
}

LuaResult:

package cn.com.cgbchina.image.nginx;

import lombok.Getter;
import lombok.Setter;

/**
 * Comment: 用来保存返回结果,
 * 原本想放入到LuaImageServiceImpl的内部类中,
 * 但是Jackson不支持,没法反序列化
 * Created by ldaokun2006 on 2017/10/24.
 */
@Setter
@Getter
public class LuaResult{
    private LuaResultStatus status;
    private String result;
    private String msg;
    private String httpUrl;
    public LuaResult(){}

    public void setStatus(String result){
        status=LuaResultStatus.valueOf(result.toUpperCase());
    }
    public enum LuaResultStatus{
        SUCCESS,FAILED;
    }
}

ImageServerImpl:

package cn.com.cgbchina.image.nginx;

import cn.com.cgbchina.common.utils.DateHelper;
import cn.com.cgbchina.image.ImageServer;
import cn.com.cgbchina.image.exception.ImageDeleteException;
import cn.com.cgbchina.image.exception.ImageUploadException;
import com.github.kevinsawicki.http.HttpRequest;
import com.google.common.base.Splitter;
import com.spirit.util.JsonMapper;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import java.io.*;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * Comment: 实现文件上传功能
 * Created by ldaokun2006 on 2017/10/16.
 */
@Service
@Slf4j
public class LuaImageServiceImpl implements ImageServer{
    // 存放nginx服务器url的,某些架构会有多个放置图片的地方
    private List<String> httpUrls;
    private ExecutorService fixedThreadPool ;
    private Integer timeout;
    private int threadSize=50;

    public LuaImageServiceImpl(String httpUrls){
        this(httpUrls,30000);
    }

    /**
     *
     * @param httpUrls 存放nginx服务器url
     * @param timeout http超时时间
     */
    public LuaImageServiceImpl(String httpUrls,int timeout){
        this.httpUrls=Splitter.on(";").splitToList(httpUrls);
        // 没啥看得,就是想让线程池的名字易懂些
        this.fixedThreadPool= new ThreadPoolExecutor(threadSize, threadSize,
                0L, TimeUnit.MILLISECONDS,
                new LinkedBlockingQueue<Runnable>(),new ThreadFactory(){
                    private final AtomicInteger poolNumber = new AtomicInteger(1);
                    private final ThreadGroup group;
                    private final AtomicInteger threadNumber = new AtomicInteger(1);
                    private final String namePrefix;

                    {
                        SecurityManager s = System.getSecurityManager();
                        group = (s != null) ? s.getThreadGroup() :
                                Thread.currentThread().getThreadGroup();
                        namePrefix = "LuaUploadPool-" +
                                poolNumber.getAndIncrement() +
                                "-thread-";
                    }

                    public Thread newThread(Runnable r) {
                        Thread t = new Thread(group, r,
                                namePrefix + threadNumber.getAndIncrement(),
                                0);
                        if (t.isDaemon())
                            t.setDaemon(false);
                        if (t.getPriority() != Thread.NORM_PRIORITY)
                            t.setPriority(Thread.NORM_PRIORITY);
                        return t;
                    }
                });
        this.timeout=timeout;
    }

    /**
     * Comment: 没必要开发删除功能
     * @param fileName 文件名
     * @return
     * @throws ImageDeleteException
     */
    @Override
    public boolean delete(String fileName) throws ImageDeleteException {
        return true;
    }

    /**
     * Commont: 用来给SpringMVC用
     * @param originalName 原始文件名
     * @param file 文件
     * @return
     * @throws ImageUploadException
     */
    @Override
    public String upload(String originalName, MultipartFile file) throws ImageUploadException {
        try {
            return this.upload(originalName,file.getInputStream());
        } catch (IOException e) {
            log.error("upload fail : " + e.getMessage(), e);
            throw new ImageUploadException("upload fail : "+e.getMessage(),e);
        }
    }

    /**
     * Commont: 上传图片核心代码
     * @param originalName 原始文件名
     * @param inputStream 要上传文件的文件流
     * @return
     * @throws ImageUploadException
     */
    private String upload(String originalName,InputStream inputStream) throws ImageUploadException {
        ByteArrayOutputStream byteOutStream = null;
        try {
            //准备数据
            byte[] tmpData=new byte[1024];
            byte[] inputData;
            byteOutStream = new ByteArrayOutputStream();
            int len=0;
            while((len=inputStream.read(tmpData,0,tmpData.length))!=-1){
                byteOutStream.write(tmpData,0,len);
            }
            inputData=byteOutStream.toByteArray();
            LuaSend sendInfo = new LuaSend(generateFileDir(),generateFileName(originalName));
            List<Future<LuaResult>> resultList=new ArrayList<>(httpUrls.size());

            //发送图片
            for(String httpUrl:httpUrls) {
                SendImg sendImg = new SendImg(httpUrl,sendInfo, inputData,this.timeout);
                resultList.add(fixedThreadPool.submit(sendImg));
            }
            for(Future<LuaResult> future:resultList) {
                // 线程池异常在这里抛出
                LuaResult resultLuaResult = future.get();
                if (LuaResult.LuaResultStatus.SUCCESS != resultLuaResult.getStatus()) {
                    throw new ImageUploadException("lua result url:"+resultLuaResult.getHttpUrl()+" msg : " + resultLuaResult.getMsg());
                }
            }

            return sendInfo.toString();
        }catch (Exception e){
            log.error("upload fail : "+e.getMessage(),e);
            throw new ImageUploadException("upload fail : "+e.getMessage(),e);
        }finally {
            try {
                if(byteOutStream!=null) {
                    byteOutStream.close();
                }
                if(inputStream!=null) {
                    inputStream.close();
                }
            } catch (IOException e) {
                throw new ImageUploadException("upload fail : "+e.getMessage(),e);
            }
        }
    }
    String separator=File.separator;
    String dateFormat=separator+"yyyy"+separator+"MM"+separator+"dd"+ separator;

    /**
     * Comment:根据时间做路径,防止某一个文件夹东西太多
     * @return 返回要保存的路径
     */
    private String generateFileDir(){
        return DateHelper.date2string(new Date(),dateFormat);
    }

    /**
     * Comment: 用UUID防止文件名重复
     * @param originalName 源文件名字
     * @return 要保存的文件名
     */
    private String generateFileName(String originalName){
        return UUID.randomUUID().toString();
    }

    /**
     * Comment: 用来发送图片的
     */
    @AllArgsConstructor
    class SendImg implements  Callable<LuaResult>{

        private String httpUrl;
        private LuaSend sendInfo;
        private byte[] inputStream;
        private Integer timeout;


        @Override
        public LuaResult call() throws Exception {
            try {
                String resultStr = HttpRequest
                        .post(httpUrl, false)
                        .part("fileInfo", JsonMapper.JSON_NON_EMPTY_MAPPER.toJson(sendInfo))
                        // 这个地方有个坑,part上传图片必须要用这个方式,
                        // 不能用没有Content-Type和fileName的
                        .part("file", sendInfo.getFileName(), "multipart/form-data; boundary=00content0boundary00", new ByteArrayInputStream(inputStream))
                        .connectTimeout(timeout).body();
                log.info("result:"+resultStr);
                LuaResult result = JsonMapper.JSON_NON_DEFAULT_MAPPER.fromJson(resultStr, LuaResult.class);
                result.setHttpUrl(httpUrl);
                return result;
            }catch(Exception e){
                throw new ImageUploadException("upload failed url:"+httpUrl+" info:"+sendInfo.toString(),e);
            }
        }
    }

    /**
     * Comment:文件数据
     */
    @Setter
    @Getter
    @AllArgsConstructor
    class LuaSend {
        // 文件目录
        private String fileDir;
        // 文件名
        private String fileName;
        @Override
        public String toString(){
            return fileDir+fileName;
        }
    }


    /**
     * Comment:测试用
     * @param args
     * @throws ImageUploadException
     * @throws FileNotFoundException
     */
    public static void main(String[] args) throws ImageUploadException, FileNotFoundException {
        LuaImageServiceImpl service=new LuaImageServiceImpl("http://192.168.99.102/uploadimage");
        try {
            System.out.println(service.upload("qqqqq", new FileInputStream("D:\shsh.txt")));
        }finally {
            service.fixedThreadPool.shutdown();
        }
    }
}

总结

可能出现的问题

  1. 上传两个图片或图片信息时系统只保留最后一个信息
  2. 图片和图片信息可以随意放置,但是这两个必须成对发送,建议先发送图片信息后发送图片,这样图片不用在lua处保存到内存中
  3. 上传大图片时会出现文件太大的提示,需要在nginx配置文件中添加client_max_body_size 100M;
  4. Http Header的Content-Type必须使用multipart/form-data;
    boundary=00content0boundary00,boundary必须存在不然不好用
  5. 传送图片HttpRequest.part上传图片必须写明Content-type和fileName,不然不好用但是Content-type不用非的用例子上的方式
  6. 图片信息必须拷贝成byte型,因为多线程使用时需要各自发送

开发中遇到的问题

  1. 传送图片HttpRequest.part上传图片必须写明Content-type,不然不好用
  2. Jackson和fastjson对于需要反序列化的类,必须有无参构造函数,并且不能是内部类
  3. lua的string.find如果没有找到,返回结果为nil
  4. CSDN的编辑器,无需功能不好用

涉及到知识

  1. HttpRequest.part用来上传Content-type:multipart/form-data;
  2. lua的使用:http://www.runoob.com/lua/lua-tutorial.html
  3. openresty的api:http://openresty.org/cn/components.html

openresty下lua的function定义及调用

本文主要研究下如何在openresty下lua的function定义及调用。

源码示例

/usr/local/openresty/lualib/resty/string.lua

-- Copyright (C) by Yichun Zhang (agentzh)


local ffi = require "ffi"
local ffi_new = ffi.new
local ffi_str = ffi.string
local C = ffi.C
local setmetatable = setmetatable
local error = error
local tonumber = tonumber


local _M = { _VERSION = '0.09' }


ffi.cdef[[
typedef unsigned char u_char;

u_char * ngx_hex_dump(u_char *dst, const u_char *src, size_t len);

intptr_t ngx_atoi(const unsigned char *line, size_t n);
]]

local str_type = ffi.typeof("uint8_t[?]")


function _M.to_hex(s)
    local len = #s * 2
    local buf = ffi_new(str_type, len)
    C.ngx_hex_dump(buf, s, #s)
    return ffi_str(buf, len)
end


function _M.atoi(s)
    return tonumber(C.ngx_atoi(s, #s))
end


return _M

实例

demo.lua

local _M = { _VERSION = '0.01' }
function _M.hello()
    ngx.say("hello from demo module!")
end
return _M

conf

        location /function {
            content_by_lua '
                local demo = require("demo")
                demo.hello()
            ';
        }

报错

2018/03/26 16:24:15 [error] 5#5: *1 lua entry thread aborted: runtime error: content_by_lua(nginx.conf:69):2: module 'demo' not found:
    no field package.preload['demo']
    no file '/usr/local/openresty/lualib/demo.lua'
    no file '/usr/local/openresty/lualib/demo/init.lua'
    no file './demo.lua'
    no file '/usr/local/openresty/luajit/share/luajit-2.1.0-beta2/demo.lua'
    no file '/usr/local/share/lua/5.1/demo.lua'
    no file '/usr/local/share/lua/5.1/demo/init.lua'
    no file '/usr/local/openresty/luajit/share/lua/5.1/demo.lua'
    no file '/usr/local/openresty/luajit/share/lua/5.1/demo/init.lua'
    no file '/usr/local/openresty/lualib/demo.so'
    no file './demo.so'
    no file '/usr/local/lib/lua/5.1/demo.so'
    no file '/usr/local/openresty/luajit/lib/lua/5.1/demo.so'
    no file '/usr/local/lib/lua/5.1/loadall.so'
stack traceback:
coroutine 0:
    [C]: in function 'require'
    content_by_lua(nginx.conf:69):2: in function <content_by_lua(nginx.conf:69):1>, client: 192.168.99.1, server: , request: "GET /function HTTP/1.1", host: "192.168.99.100:8686"

修复

ADD demo.lua /usr/local/openresty/lualib/demo.lua

小结

从源码可以看出,基本是定义一个_M变量,里头有个_VERSION属性,然后定义_M的function,最后返回_M。另外注意自己定义的类库需要放在openresty查找的路径下面,否则会报错。

OpenResty之单元测试

当项目功能开发完毕时,我们常常为了保证自己代码的功能正常,符合自己的预期结果而编写单元测试,并且以后当我们修改代码后,只需要跑一边单元测试便可以看我们有没有把代码给改残了。所以单元测试的重要性不言而喻。

使用不同的编程语言编写代码时,通常都有不同的单元测试框架。而当我们编写OpenResty项目时,比较好用的的测试框架还是比较少等,因为之前一直在看《OpenResty 最佳实践》一书,里面作者自己开发了一套测试框架lua-resty-test,使用后感觉还不错,便在项目中使用了一下。下面大概总结下使用心得。

Nginx 配置添加测试服务器

在配置文件中新增一个专门用于跑单元测试的服务器,并且将它的错误日志专门用另一个test_error.log文件存储。然后新建一个test文件夹用于放置单元测试代码,设置执行单元测试的主入口文件为main.lua,
这里别忘了设置下lua_package_path的值,把lua-resty-test的路径加上去。因为我吧项目的一些公共库都放置在了lualib下,所以我的lua_package_path设置如下

# conf/nginx.conf
lua_package_path '${prefix}project/?.lua;${prefix}lualib/?.lua;;';
# 新增的单元测试服务器配置
server {
    listen  8090;
    access_log off;
    # 此处修改单元测试服务器的错误日志为test_error.log,有别于开发项目的 error.log文件
    error_log logs/test_error.log error;
    # 主入口文件为 mian.lua
    location /test {
        content_by_lua_file 'test/main.lua';
    }
}

在主入口文件中加载各个单元测试代码

因为我这里是按照一个文件编写一个单元测试的,所以在主入口文件中进行加载每一个要被测试的代码,代码如下

# test/main.lua
local iresty_test    = require "resty.iresty_test"
-- 测试项目中的utils.lua文件
require("test.test_utils.test_utils")(iresty_test)
-- 测试项目中的transac.lua文件
-- require("test.test_controller.test_transac")(iresty_test)

utils 文件的单元测试代码大致如下

 # test/test_utils/test_utils.lua

 local utils = require("utils.utils")
 local cjson = require("cjson")

 local function test_utils( iresty_test )

     -- 设置此模块的单元测试名称
     local test_utils = iresty_test.new({unit_name="test_utils"})

     -- init方法可以在跑单元测试的时候执行一些初始化的操作
     --function test_utils:init(  )
     --    self:log("init complete")
     --end

     -- 测试utils模块的isDigit函数
     function test_utils:test_isDigit()
         -- case one
         local result = utils.isDigit()
         local expect = false
         assert(expect == result, "isDigit function should return false")

         -- case two 
         local result = utils.isDigit("")
         local expect = false
         assert(expect == result, "isDigit function should return false")

         -- case three 
         local result = utils.isDigit("12345")
         local expect = true
         assert(expect == result, "isDigit function should return true")

         -- case four 
         local result = utils.isDigit("123abc")
         local expect = false
         assert(expect == result, "isDigit function should return true")
     end

     -- 测试utils模块的trim函数
     function test_utils:test_trim()
         -- log方法用于记录单元测试的时候,一些日志数据
         -- self:log("ok")
     end

     -- 运行此文件的单元测试
     test_utils:run()
end

return test_utils

运行单元测试代码

建议在命令行中使用curl命令,这样的话可以比较明显的看出不同颜色的输出日志

curl http://10.100.157.198:8090/test

输出效果如下

0.000 [test_utils] unit test start
0.000   _[test_isDigit] PASS
0.000   _[test_trim] PASS
0.000 [test_utils] unit test complete

OpenResty 访问系统环境变量

在编写程序时,我们经常会依据不同的环境使用不同的配置,之前一直以为 OpenResty 无法访问系统的环境变量,所以使用一种很 low 的方式去加载不同环境的配置。现在才发现自己还是对 Nginx 配置了解的不够全。其实 Nginx 的核心功能中就存在一个 env指令,可以实现我们的需求。下面来说明下这个 env 指令。

env

Syntax: env variable[=value];
Context: main

默认情况下,nginx 会移除所有从父进程继承的环境变量,如果你想使用这些环境变量,需要使用该指令显示告知nginx不要移除你指定的环境变量。而且你也可以更改它们的值或创建新的环境变量。

例如

env PROJECT_MODE;
# 也可以使用env PROJECT_MODE=DEV; 来覆盖父进程的环境变量

则 nginx 会保留系统的 PROJECT_MODE 环境变量,
然后在lua脚本中,我们就可以通过 lua 的os.getenv()来获取对应的环境变量了,并依据不同的环境使用不同的配置

# config.lua
local env = os.getenv("PROJECT_MODE")
local config = {}
-- 开发环境配置
if env == "DEV" then

-- 生产环境配置
elseif env == "PROD" then

end

return config

Linux下安装配置OpenResty,并测试在Nginx中使用Lua编程

一、简介

OpenResty,也被称为“ngx_openresty”,是一个以Nginx为核心同时包含很多第三方模块的Web应用服务器。借助于Nginx的事件驱动模型和非阻塞IO,可以实现高性能的Web应用程序。 OpenResty不是Nginx的分支,它只是一个软件包。主要有章亦春维护。

OpenResty默认集成了Lua开发环境,而且提供了大量组件如Mysql、Redis、Memcached等,使得在Nginx上开发Web应用更方便简单。

二、安装OpenResty

[root@hbase31 src]# wget https://openresty.org/download/openresty-1.11.2.5.tar.gz
[root@hbase31 src]# tar -zxvf openresty-1.13.6.1.tar.gz
[root@hbase31 openresty-1.13.6.1]# ./configure --prefix=/usr/local/openresty --user=www --group=www --with-http_stub_status_module --with-http_ssl_module --with-openssl=/usr/local/ssl --with-pcre=/usr/local/src/pcre-8.38 --add-module=/usr/local/src/ngx_cache_purge-2.3 --with-http_gzip_static_module --with-luajit
[root@hbase31 openresty-1.13.6.1]# make && make install

注:关于这里的编译参数可以认为是在Nginx的编译参数的基础上添加了其他组件的参数。如需查看更多参数可以使用以下命令:

[root@hbase31 openresty-1.13.6.1]# ./configure --help

配置nginx的启动脚本:

[root@hbase31 openresty-1.13.6.1]# vim /etc/init.d/nginx

添加如下内容:

#!/bin/bash
# nginx Startup script for the Nginx HTTP Server
# it is v.1.3.0 version.
# chkconfig: - 85 15
# description: Nginx is a high-performance web and proxy server.
#              It has a lot of features, but it's not for everyone.
# processname: nginx
# pidfile: /var/run/nginx.pid
# config: /usr/local/openresty/nginx/conf/nginx.conf
nginxd=/usr/local/openresty/nginx/sbin/nginx
nginx_config=/usr/local/openresty/nginx/conf/nginx.conf
nginx_pid=/usr/local/openresty/nginx/logs/nginx.pid
RETVAL=0
prog="nginx"
# Source function library.
.  /etc/rc.d/init.d/functions
# Source networking configuration.
.  /etc/sysconfig/network
# Check that networking is up.
[ ${NETWORKING} = "no" ] && exit 0
[ -x $nginxd ] || exit 0
# Start nginx daemons functions.
start() {
if [ -e $nginx_pid ];then
   echo "nginx already running...."
   exit 1
fi
   echo -n $"Starting $prog: "
   daemon $nginxd -c ${nginx_config}
   RETVAL=$?
   echo
   [ $RETVAL = 0 ] && touch /var/lock/subsys/nginx
   return $RETVAL
}
# Stop nginx daemons functions.
stop() {
        echo -n $"Stopping $prog: "
        killproc $nginxd
        RETVAL=$?
        echo
        [ $RETVAL = 0 ] && rm -f /var/lock/subsys/nginx $nginx_pid
}
reload() {
    echo -n $"Reloading $prog: "
    #kill -HUP `cat ${nginx_pid}`
    killproc $nginxd -HUP
    RETVAL=$?
    echo
}
# See how we were called.
case "$1" in
start)
        start
        ;;
stop)
        stop
        ;;
reload)
        reload
        ;;
restart)
        stop
        start
        ;;

status)
        status $prog
        RETVAL=$?
        ;;
*)
        echo $"Usage: $prog {start|stop|restart|reload|status|help}"
        exit 1
esac
exit $RETVAL

添加可执行权限:

[root@hbase31 openresty-1.13.6.1]# chmod a+x /etc/init.d/nginx

启动nginx:

[root@hbase31 openresty-1.13.6.1]# service nginx start

三、在Nginx中使用Lua脚本

[root@hbase31 vhost]# cd /usr/local/openresty/nginx/conf
[root@hbase31 conf]# mkdir lua vhost

(1)测试在Nginx中使用Lua脚本

[root@hbase31 nginx]# vim /usr/local/openresty/nginx/conf/vhost/lua.conf

其内容如下:

server {
    server_name localhost;
    listen 3000;
    index index.html index.htm index.jsp;

    location / {
        root /usr/local/openresty/nginx/html;
    }

    location /lua {
        default_type text/plain;
        content_by_lua 'ngx.say("hello,lua!")';
    } 

    limit_conn perip 1000;
    access_log logs/access_rua.log;
}

测试是否可以访问:

[root@hbase31 nginx]# service nginx reload

然后访问:http://192.168.1.31:3000/lua

如果输出以下内容则证明在Nginx中可以执行Lua脚本:

hello,lua!

(2)在Nginx中使用Lua脚本访问Redis

i)连接Redis集群,然后添加测试参数:

192.168.1.30:7000> set '123' '456'

ii)添加连接Redis的Lua脚本:

[root@hbase31 nginx]# vim /usr/local/openresty/nginx/conf/lua/redis.lua

其内容如下:

local redis = require "resty.redis"
local conn = redis.new()
conn.connect(conn, '192.168.1.30', '7000')
local res = conn:get("123")
if res==ngx.null then
    ngx.say("redis集群中不存在KEY——'123'")
    return
end
ngx.say(res)

iii)在上面的lua.conf配置文件中添加以下location:

    location /lua_redis {
        default_type text/plain;
        content_by_lua_file /usr/local/openresty/nginx/conf/lua/redis.lua;
    }
1
2
3
4
    location /lua_redis {
        default_type text/plain;
        content_by_lua_file /usr/local/openresty/nginx/conf/lua/redis.lua;
    }

iv)测试是否可以访问:

[root@hbase31 nginx]# service nginx reload

然后访问:http://192.168.1.31:3000/lua_redis

如果输出以下内容则证明可以访问redis:

456

Openresty最佳案例 | 第9篇:Openresty实现的网关权限控制

简介

采用openresty 开发出的api网关有很多,比如比较流行的kong、orange等。这些API 网关通过提供插件的形式,提供了非常多的功能。这些组件化的功能往往能够满足大部分的需求,如果要想达到特定场景的需求,可能需要二次开发,比如RBAC权限系统。本小节通过整合前面的知识点,来构建一个RBAC权限认证系统。

技术栈

本小节采用了以下的技术栈:

  • Openresty(lua+nginx)
  • mysql
  • redis
  • cjson

验证流程

  • 用户请求经过nginx,nginx的openresty的模块通过拦截请求来进行权限判断
  • openresty的access_by_lua_file模块,进行了一系列的判断
    • 用户的请求是否为白名单uri,如果为白名单uri,则直接通过验证, 进入下一个验证环节content_by_lua_file,这个环节直接打印一句话:“恭喜,请求通过。”
    • 如果用户请求不为白名单url,则需要取出请求header中的token,如果请求的header不存在token,则直接返回结果401,无权限访问。
      如果用户请求的uri的请求头包含token ,则取出token,解密token取出用户id
    • 根据取出的userid去查询数据库获取该用户的权限,如果权限包含了该请求的uri,请求可以通过,否则,请求不通过。
  • 请求如果通过access_by_lua_file模块,则进入到content_by_lua_file模块,该模块直接返回一个字符串给用户请求,在实际的开发中,可能为路由到具体的应用程序的服务器。

验证流程图如下所示:

未分类

vim /usr/example/example.conf ,加上以下的配置:

 location / {
    default_type "text/html";
    access_by_lua_file /usr/example/lua/api_access.lua;
    content_by_lua_file /usr/example/lua/api_content.lua;
  }

以上的配置表示,要不符合已有location路径的所有请求,将走这个location为/ 的路径。符合这个location的请求将进入 access_by_lua_file和 content_by_lua_file的模块判断。

vim /usr/example/lua/access_by_lua_file ,加上以下代码:

local tokentool = require "tokentool"
local mysqltool = require "mysqltool"

 function is_include(value, tab)
   for k,v in ipairs(tab) do
      if v == value then
           return true
       end
    end
    return false
 end

local white_uri={"/user/login","/user/validate"}

--local user_id = ngx.req.get_uri_args()["userId"]
--获取header的token值
local headers = ngx.req.get_headers() 
local token=headers["token"]
local url=ngx.var.uri
if ( not token) or (token==null) or (token ==ngx.null) then
  if is_include(url,white_uri)then

  else
    return ngx.exit(401)
  end  
else 
  ngx.log(ngx.ERR,"token:"..token)
  local user_id=tokentool.get_user_id(token)
  if (not user_id) or( user_id ==null) or ( user_id == ngx.null) then
      return ngx.exit(401)   
  end 

  ngx.log(ngx.ERR,"user_id"..user_id)
  local permissions={}
  permissions =tokentool.get_permissions(user_id)
  if(not permissions)or(permissions==null)or( permissions ==ngx.null) then
      permissions= mysqltool.select_user_permission(user_id)
      if permissions and permissions ~= ngx.null then
         tokentool.set_permissions(user_id,permissions)
      end
  end  
  if(not permissions)or(permissions==null)or( permissions ==ngx.null) then
     return ngx.exit(401)
  end 
  local is_contain_permission = is_include(url,permissions) 

  if is_contain_permission == true  then
     -- ngx.say("congratuation! you have pass the api gateway")
  else
      return ngx.exit(401) 
  end   
end

在上述代码中:

  • is_include(value, tab),该方法判断某个字符串在不在这个table中。
  • white_uri={“/user/login”,”/user/validate”} 是一个白名单的列表。
  • local headers = ngx.req.get_headers()从请求的uri的请求头获取token
  • is_include(url,white_uri)判断该url是否为白名单url
  • local user_id=tokentool.get_user_id(token)根据token获取该token对应的用户的user_id,在常见情况下,是根据token解析出user_id,但在不同的语言加密和加密token存在盐值不一样的情况,比较麻烦,所以我偷了个懒,直接存了redis,用户登录成功后存一下。
  • permissions =tokentool.get_permissions(user_id)根据user_id
    从redis获取该用户的权限。
  • permissions= mysqltool.select_user_permission(user_id)如果redis没有存该用户的权限,则从数据库读。
  • tokentool.set_permissions(user_id,permissions),将从数据库中读取的权限点存在reddis中。
  • local is_contain_permission = is_include(url,permissions),判断该url 在不在该用户对应的权限列表中。

如果所有的判断通过,则该用户请求的具有权限访问,则进入content_by_lua_file模块,直接在这个模块给请求返回“congratulations! you have passed the api gateway”。

vim /usr/example/lua/api_content.lua ,添加以下内容:

ngx.say("congratulations!"," you have passed ","the api gateway")  
----200状态码退出  
return ngx.exit(200)  

验证演示

打开浏览器访问http://116.196.177.123/user/login,浏览器显示:

congratulations! you have passed the api gateway

/user/login这个url 在白名单的范围内,所以它是可以通过权限验证的。

打开浏览器访问http://116.196.177.123/user/sss,显示以下内容:

401 Authorization Required

openresty/1.11.2.4

在redis中添加一对key-value,key为token_forezp,value为1,即token_forezp对应的用户的id为1.

/usr/servers/redis-3.2.6

src/redis-cli

set token_forezp 1

初始化以下的sql脚本,即给用户id为1的用户关联角色,角色并关联权限:

INSERT INTO `permission` VALUES ('1', '/user/orgs');
INSERT INTO `role` VALUES ('1', 'user');
INSERT INTO `role_permission` VALUES ('1', '1', '1');
INSERT INTO `user` VALUES ('1', 'forezp');
INSERT INTO `user_role` VALUES ('1', '1', '1');

用postman请求,在请求头中加入token,值为token_forezp,请求结果如下:

未分类

Openresty最佳案例 | 第8篇:RBAC介绍、sql和redis模块工具类

RBAC介绍

RBAC(Role-Based Access Control,基于角色的访问控制),用户基于角色的访问权限控制。简单地说,一个用户拥有若干角色,每一个角色拥有若干权限。这样,就构造成“用户-角色-权限”的授权模型。在这种模型中,用户与角色之间,角色与权限之间,一般都是多对多的关系。如图所示:

未分类

sql_tool

在本案例中,采用的就是这种权限设计的方式。具体的sql语句脚本如下:

CREATE TABLE `user` (
`id`  int(11) NOT NULL AUTO_INCREMENT ,
`name`  varchar(255) CHARACTER SET latin1 COLLATE latin1_swedish_ci NULL DEFAULT NULL ,
PRIMARY KEY (`id`)
)
ENGINE=InnoDB
DEFAULT CHARACTER SET=latin1 COLLATE=latin1_swedish_ci
AUTO_INCREMENT=2
ROW_FORMAT=COMPACT
;


CREATE TABLE role(
`id`  int(11) NOT NULL AUTO_INCREMENT ,
`name`  varchar(255) CHARACTER SET latin5 NULL DEFAULT NULL ,
PRIMARY KEY (`id`)
)
ENGINE=InnoDB
DEFAULT CHARACTER SET=latin1 COLLATE=latin1_swedish_ci
AUTO_INCREMENT=2
ROW_FORMAT=COMPACT
;

CREATE TABLE permission(
`id`  int(11) NOT NULL AUTO_INCREMENT ,
`permission`  varchar(255) CHARACTER SET latin1 COLLATE latin1_swedish_ci NULL DEFAULT NULL ,
PRIMARY KEY (`id`)
)
ENGINE=InnoDB
DEFAULT CHARACTER SET=latin1 COLLATE=latin1_swedish_ci
AUTO_INCREMENT=3
ROW_FORMAT=COMPACT
;

CREATE TABLE user_role(
`id`  int(11) NOT NULL AUTO_INCREMENT ,
`user_id`  int(11) NULL DEFAULT NULL ,
`role_id`  int(11) NULL DEFAULT NULL ,
PRIMARY KEY (`id`)
)
ENGINE=InnoDB
DEFAULT CHARACTER SET=latin1 COLLATE=latin1_swedish_ci
AUTO_INCREMENT=2
ROW_FORMAT=COMPACT
;


CREATE TABLE role_permission(
`id`  int(11) NOT NULL AUTO_INCREMENT ,
`role_id`  int(11) NULL DEFAULT NULL ,
`permission_id`  int(11) NULL DEFAULT NULL ,
PRIMARY KEY (`id`)
)
ENGINE=InnoDB
DEFAULT CHARACTER SET=latin1 COLLATE=latin1_swedish_ci
AUTO_INCREMENT=3
ROW_FORMAT=COMPACT
;

初始化以下的sql脚本,即给用户id为1的用户关联角色,角色并关联权限:

INSERT INTO `permission` VALUES ('1', '/user/orgs');
INSERT INTO `role` VALUES ('1', 'user');
INSERT INTO `role_permission` VALUES ('1', '1', '1');
INSERT INTO `user` VALUES ('1', 'forezp');
INSERT INTO `user_role` VALUES ('1', '1', '1');

在本案例中,需要根据user表中的Id获取该Id对应的权限。首先根据userId获取该用户对应的角色,再根据根据该角色获取相应的权限,往往一个用户具有多个角色,而角色又有多个权限。比如查询userId为1 的用户的权限的sql语句如下:

SELECT  a.id,a.permission from permission a ,role_permission b,role c,user_role d,user e WHERE a.id=b.permission_id and c.id=b.role_id and d.role_id=c.id and d.user_id=e.id and e.id=1"

在Openresty中怎么连接数据库,怎么查询sql语句,在之前的文章已将讲述过了。根据用户id获取用户的权限的功能是一个使用率极高的功能,所以考虑将这个功能模块化。

vim /usr/example/lualib/sql_tool.lua ,编辑加入以下的代码:

local mysql = require("resty.mysql")  

local function close_db(db)  
    if not db then  
        return  
    end  
    db:close()  
end  

local function select_user_permission(user_id)

   local db, err = mysql:new()
   if not db then  
      ngx.say("new mysql error : ", err)  
      return  
   end 
   db:set_timeout(1000)  

   local props = {  
      host = "127.0.0.1",  
      port = 3306,  
      database = "test",  
      user = "root",  
      password = "123"  
   }

  local res, err, errno, sqlstate = db:connect(props)  

  if not res then  
     ngx.say("connect to mysql error : ", err, " , errno : ", errno, " , sqlstate : ", sqlstate)  
     close_db(db)
  end

  local select_sql = "SELECT  a.id,a.permission from permission a ,role_permission b,role c,user_role d,user e WHERE a.id=b.permission_id and c.id=b.role_id and d.role_id=c.id and d.user_id=e.id and e.id="..user_id
  res, err, errno, sqlstate = db:query(select_sql)  
  if not res then  
     ngx.say("select error : ", err, " , errno : ", errno, " , sqlstate : ", sqlstate)  
     return close_db(db)  
  end  

   local permissions={}
   for i, row in ipairs(res) do  
     for name, value in pairs(row) do
    if name == "permission" then
          table.insert(permissions, 1, value)
        end  

     end  
   end  
 return permissions 
end

local _M = {  
    select_user_permission= select_user_permission
}  

return _M  

在上面的代码中,有一个select_user_permission(user_id)方法,该方法根据用户名获取该用户的权限。查出来存在一个table 类型的 local permissions={}中。

vim /usr/example/example.conf 加上以下的代码:

location ~ /sql_tool{
  default_type 'text/html';
  content_by_lua_file /usr/example/lua/test_sql_tool.lua;
 }

在浏览器上访问http://116.196.177.123/sql_tool,浏览器显示如下的内容:

/user/orgs

tokentool

在之前的文章讲述了如何使用Openresty连接redis,并操作redis。 这小节将讲述如何使用openresty连接redis,并写几个方法,用于存储用户的token等,并将这些信息模块化,主要有以下几个方法:

  • close_redis(red) 通过连接池的方式释放一个连接
  • connect() 连接redis
  • has_token(token) redis中存在token 与否
  • get_user_id(token) 根据token获取用户id
  • set_permissions(user_id,permissions) 根据userid设置权限
  • get_permissions(user_id)根据userid获取权限

vim /usr/example/lualib/tokentool.lua 编辑一下内容:

module("tokentool", package.seeall)
local redis = require "resty.redis"
local str = require "resty.string"
local cjson = require("cjson")  


local redis_host = "127.0.0.1"
local redis_port = 6379

local function close_redis(red)  
    if not red then  
        return  
    end  
    local pool_max_idle_time = 10000 --毫秒  
    local pool_size = 100 --连接池大小  
    local ok, err = red:set_keepalive(pool_max_idle_time, pool_size)  
    if not ok then  
        ngx.say("set keepalive error : ", err)  
    end  
end 

local function connect()
    local red = redis:new()
    red:set_timeout(1000)
    local ok, err = red:connect(redis_host, redis_port)
    if not ok then
        return false
    end
    --local res, err = red:auth("xiaoantimes")
    --if not res then
     -- ngx.say("failed to authenticate: ", err)
     -- return false
    --end
    --ok, err = red:select(1)
    --if not ok then
      --  return false
    --end
    return red
end

function has_token(token)
    local red = connect()
    if red == false then
        return false
    end

    local res, err = red:get(token)
    if not res then
        return false
    end
    close_redis(red)  
    return true
end

function set_permissions(user_id,permissions)
  if (permissions==null) or( permissions==ngx.null) then
     return false
  end 
  local str = cjson.encode(permissions)  
  ngx.log(ngx.ERR,"set redis p:"..str)
  local red=connect()
  if red== false then
     return false
  end
  local ok, err = red:set(user_id,str)
  if not ok then
     return false
  end
  return true 
end

function get_permissions(user_id)
  local red=connect()
  if red== false then
     return false
  end
  local res, err = red:get(user_id)
  if (not res) or (res == ngx.null) then
     return
  end 
  ngx.log(ngx.ERR,"get redis p:"..res);
  local permissions=cjson.decode(res)  
  return permissions
end

function get_user_id(token)
    local red = connect()
    local resp, err = red:get(token)  
    if not resp then  
      ngx.say("get msg error : ", err)  
      return close_redis(red)  
    end  
    close_redis(red)  
    return resp
end

vim /usr/example/lua/test_token_tool.lua,加上以下的内容:

local tokentool= require "tokentool"
local ret = tokentool.has_token("msg")
ngx.log(ngx.ERR,ret)
if ret == true then
   ngx.say("ok")
else
   ngx.say("oops,error")
end

在/usr/example/example.conf加上以下的内容:

 location ~ /token_tool{
     default_type 'text/html';
     lua_code_cache on;
     content_by_lua_file /usr/example/lua/test_token_tool.lua;

 }

打开浏览器访问http://116.196.177.123/token_tool,浏览器显示:

ok