天涯旅店

Nginx 使用 OpenResty+Lua 脚本进行鉴权

公司做了个新项目,把设计类的资源和页面放到一个纯前端网站上,方便内部人员查阅和使用。但是在上线以后才发现存在致命问题,大量人员无法访问。

出于安全考虑,设计官网的域名只在内网 DNS 解析,但是业务部门使用的网络并不能访问数据中心内网,导致只有科技部门能使用这个网站。于是要开放公网 DNS 解析,加一层身份验证,确认是公司员工使用。

安排下面的开发做了个后台服务,接入公司统一认证系统,做完一看思路完全错了:这个身份验证是由前端 js 控制的,事实上形同虚设。

制定方案

由于前后端分离架构深入人心,设计官网新增的后台验证服务也是按这个模式做的。但是这就有点生搬硬套了,主流前后端分离架构中需要保护的是用户存储的数据,前端资源只是渲染页面;而在当前需求的场景下,需要保护的反而是前端的资源数据。等到把前端 html、js、css、图片都暴露了再跳到登录页面,已经是数据泄露了。

所以很自然就想到了使用传统的 MVC 架构,把前端的文件都放到后端服务里,后端做一个全局的验证就好了。经过讨论后发现这个方案也实现不了,同事说这个前端项目是基于 node 服务搭建的,改成文件形式需要重新开发,工作量还不小。

无奈之下只能另辟蹊径,最终得出三个方案。

  • 用 Java 实现一个简单的 HTTP 代理服务,所有前端资源都用代理转发,在其中加入一个调用第三方鉴权的功能。
  • 在 Node 服务器上加一个调用鉴权的功能。
  • 在 Nginx 上加一个调用鉴权的功能。

虽然没用过 Nginx+Lua 的方式,但是有了解到这个用法,我觉得这是个通用性很高也很成熟的方案,所以就基于这个方案开始实现。

使用 OpenResty

Nginx 官方是没有 Lua 模块的,所以手动引入 OpenResty 开发的 ngx_http_lua_module 不如直接使用 OpenResty。

OpenResty 是一个基于 NginxLua 的高性能 Web 平台,其内部集成了大量精良的 Lua 库、第三方模块以及大多数的依赖项。用于方便地搭建能够处理超高并发、扩展性极高的动态 Web 应用、Web 服务和动态网关。

安装方法和 Nginx 没有什么太大差异,在 Windows 直接下载就能用,Ubuntu 用 apt 安装的。可以用工具,也可以源码编译安装,详见官网

Lua 语言

Lua 是一种轻量小巧的脚本语言,用标准 C 语言编写并以源代码形式开放, 其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能。

有着胶水语言之称的 Lua,以前没有用过,第一次看到还是魔兽世界的插件,基础语法比较简单,可以参考着看下。

代码逻辑处理

获取 token

开始代码编写,第一步是从请求中获取鉴权参数,如果没获取到,跳转到登录页面。

--从cookie中获取token值
local token = ngx.var.cookie_token
--判断token是否为空,为空跳转
if not token then
    ngx.redirect("https://login.example.com/login?appid=abc&redirect=xxx", 302)
end

或者从 URL 中获取 token。

local token = ngx.var.arg_token

发起鉴权

我们是用 HTTP 调用鉴权服务实现的,也可以使用数据库或者 Redis,性能会更好。

发起 HTTP 请求需要单独安装 lua-resty-http 模块,可以从 https://github.com/pintsized/lua-resty-http/tree/master/lib/resty 下载下面两个文件:

  • http.lua
  • http_headers.lua

然后放到 openresty/lualib/resty 下就好。

或者使用 LuaRocks 安装,LuaRocks 类似于 Lua 的 npm,建议装一个,方便快捷。

[root@localhost ~]# apt install luarocks
[root@localhost ~]# luarocks install lua-resty-http

安装完后引入模块,发起请求:

--引入http模块
local http = require "resty.http"
local client = http.new()
local url = "https://login.example.com/token/check"
local res, err = client:request_uri(url, {
    method = "POST",
    headers = {
        ["authorization"] = token,
        ["clientId"] = abc,
        ["clientSecret"] = xxx,
    }
})

HTTPS 证书 处理

代码中和我们使用浏览器或者 curl 命令不太一样,没法自动处理证书,发起 HTTPS 请求会有异常报错。

lua ssl certificate verify error: (19: self signed certificate in certificate chain)

需要手动配置证书,在 nginx.conf 中加入配置,如果鉴权地址是 HTTP 协议,可以忽略。

http {
    lua_ssl_verify_depth 2;
    lua_ssl_trusted_certificate ../cmsk1979.com.pem;
}

响应结果校验

对返回的响应结果进行校验。

--判断接口返回结果状态
if not res then
    ngx.log(ngx.ERR, "failed to request: ", err)
    ngx.redirect("https://login.example.com", 302)
end

--状态码
ngx.status = res.status
if ngx.status ~= 200 then
    ngx.log(ngx.ERR, "HTTP status error:" .. ngx.status)
    ngx.redirect("https://login.example.com", 302)
end

--响应体判断
local resStr = "false"
resStr = res.body
if resStr == "false" then
    ngx.log(ngx.ERR, "HTTP response body nil:" .. res.body)
    ngx.redirect("https://login.example.com", 302)
end

因为我们是用的 JSON 结构响应数据,还需要解析 json 判断数据正确性。

JSON 解析需要引入 JSON 解析的库,OpenResty 自带 cjson 可以直接使用。

--响应体json解析
local cjson = require("cjson")
local obj = cjson.decode(res.body)

if obj.status == "1" then
    ngx.header['Set-Cookie'] = 'token=' .. token
    ngx.redirect("http://127.0.0.1", 302)
else
    ngx.log(ngx.ERR, "check token fail" .. res.body)
    ngx.redirect("https://login.example.com", 302)
end

完整代码

组合了两个 Lua,内容都差不多,一个是从 Cookie 获取 token 验证,另一个是登录专用的,因为不喜欢 URL 里一大堆参数,所以做了两个。

--home.lua token验证

--从URI中获取token值
local token = ngx.var.cookie_token
--判断token是否为空,为空则直接重定向登录页
if not token then
    ngx.redirect("https://login.example.com/login?appid=abc&redirect=xxx", 302)
end

--引入http模块
local http = require "resty.http"
local client = http.new()
local url = "https://login.example.com/token/check"
local res, err = client:request_uri(url, {
    method = "POST",
    headers = {
        ["authorization"] = token,
        ["clientId"] = abc,
        ["clientSecret"] = xxx,
    }
})

--判断接口返回结果状态
if not res then
    ngx.log(ngx.ERR, "failed to request:", err)
    ngx.redirect("https://login.example.com", 302)
end

--请求之后,状态码
ngx.status = res.status
if ngx.status ~= 200 then
    ngx.redirect("https://login.example.com", 302)
end

--响应体判断
local resStr = "false"
resStr = res.body
if resStr == "false" then
    ngx.log(ngx.ERR, "HTTP response body nil:" .. res.body)
    ngx.redirect("https://login.example.com", 302)
end

--响应体json解析
local cjson = require("cjson")
local obj = cjson.decode(res.body)

if obj.status ~= "1" then
    ngx.redirect("https://login.example.com", 302)
end
--login.lua 登录页面

--从URI中获取token值
local token = ngx.var.arg_token
--判断token是否为空,为空则直接重定向登录页
if not token then
    ngx.redirect("https://login.example.com/login?appid=abc&redirect=xxx", 302)
end

--引入http模块
local http = require "resty.http"
local client = http.new()
local url = "https://login.example.com/token/check"
local res, err = client:request_uri(url, {
    method = "POST",
    headers = {
        ["authorization"] = token,
        ["clientId"] = abc,
        ["clientSecret"] = xxx,
    }
})

--判断接口返回结果状态
if not res then
    ngx.log(ngx.ERR, "failed to request:", err)
    return ngx.exit(403)
end

--请求之后,状态码
ngx.status = res.status
if ngx.status ~= 200 then
    ngx.log(ngx.ERR, "HTTP status error:" .. ngx.status)
    return ngx.exit(403)
end

--响应体判断
local resStr = "false"
resStr = res.body
if resStr == "false" then
    ngx.log(ngx.ERR, "HTTP response body nil:" .. res.body)
    return ngx.exit(403)
end

--响应体json解析
local cjson = require("cjson")
local obj = cjson.decode(res.body)

if obj.status == "1" then
    ngx.log(ngx.ERR, "成功")
    ngx.header['Set-Cookie'] = 'token=' .. token
    ngx.redirect("/")
else
    ngx.log(ngx.ERR, "HTTP response body status error:" .. res.body)
    return ngx.exit(403)
end

Nginx 配置

然后就是 nginx.conf 的配置,主要是加了证书的配置,还有日志的配置。

worker_processes  1;

events {
    worker_connections  1024;
}

http {
    include       mime.types;
    default_type  application/octet-stream;
    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';
    sendfile        on;
    keepalive_timeout  65;
    #DNS
    resolver 127.0.0.53;
    lua_ssl_verify_depth 2;
    lua_ssl_trusted_certificate /usr/local/openresty/nginx/pem/example.com.pem;   
    server {
        listen       80;
        server_name  localhost;

        location / {
            #热部署,修改lua文件,不用reload
            lua_code_cache on; 
            #日志地址
            access_log  logs/home.access.log main; 
            #错误日志地址
            error_log   logs/home.error.log error;
            #引入脚本
            rewrite_by_lua_file /usr/local/openresty/nginx/lua/home.lua;

            root   html;
            index  index.html index.htm;
        }

        location /login {
            #热部署,修改lua文件,不用reload
            lua_code_cache on; 
            #日志地址
            access_log  logs/login.access.log main; 
            #错误日志地址
            error_log   logs/login.error.log error;
            #引入脚本
            rewrite_by_lua_file /usr/local/openresty/nginx/lua/login.lua;
        }
        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   html;
        }
    }
}

另外一个点是 DNS 需要手动配置,否则无法自动找到 DNS 解析域名,可以配本地 DNS 或者公网的,如果是 IP 形式的鉴权地址,可以不用。

[root@localhost ~]# cat /etc/resolv.conf
# This file is managed by man:systemd-resolved(8). Do not edit.
#
# This is a dynamic resolv.conf file for connecting local clients to the
# internal DNS stub resolver of systemd-resolved. This file lists all
# configured search domains.
#
# Run "systemd-resolve --status" to see details about the uplink DNS servers
# currently in use.
#
# Third party programs must not access this file directly, but only through the
# symlink at /etc/resolv.conf. To manage man:resolv.conf(5) in a different way,
# replace this symlink by a static file or a different symlink.
#
# See man:systemd-resolved.service(8) for details about the supported modes of
# operation for /etc/resolv.conf.

nameserver 127.0.0.53
options edns0

效果展示

启动 OpenResty,一个简单的鉴权服务就搭好了,访问服务端的 80 端口,鉴权跳转认证系统,登陆成功跳转服务端的 login 页面,再重定向回首页,使用效果如下:

https://cdn.sekiro.top/openresty-lua-1.webp

在认证系统登录成功后,跳转到本地的登录页面,从 URL 拿到 token 校验,响应 Set-Cookie。

HTTP/1.1 302 Moved Temporarily
Server: openresty/1.19.9.1
Date: Fri, 17 Dec 2021 07:31:28 GMT
Content-Type: text/html
Connection: keep-alive
Set-Cookie: token=5001b1bffbb72c740e2ab6811d6cbede082d2504a352b61a9fa4512769c63cf5bff5f56de37c6619372217961b7dc267cba9
Location: /
Content-Length: 151

后面的请求就都会带 token 了。

GET / HTTP/1.1
Host: 45.77.27.186
Connection: keep-alive
Pragma: no-cache
Cache-Control: no-cache
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.55 Safari/537.36 Edg/96.0.1054.43
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Cookie: token=5001b1bffbb72c740e2ab6811d6cbede082d2504a352b61a9fa4512769c63cf5bff5f56de37c6619372217961b7dc267cba9

总结

OpenResty 的确是一个强大的 Web 服务器,我这只用了很小一部分功能,以前说过的 OAuth 2.0,也可以用 OpenResty 来实现。

虽然出于方便其他人后期维护考虑,领导还是选择了 Java 代理的方案,但是我觉得我们很多项目都用到了 Nginx,其中有大量功能都可以用 OpenResty 来替换比较重的 Tomcat,性能会更好。

希望在以后的开发中,有机会继续探索 OpenResty 的丰富功能,并应用到项目实践中去。

没有标签
首页      未分类      Nginx 使用 OpenResty+Lua 脚本进行鉴权

发表回复

textsms
account_circle
email

天涯旅店

Nginx 使用 OpenResty+Lua 脚本进行鉴权
公司做了个新项目,把设计类的资源和页面放到一个纯前端网站上,方便内部人员查阅和使用。但是在上线以后才发现存在致命问题,大量人员无法访问。 出于安全考虑,设计官网的域名只在内…
扫描二维码继续阅读
2021-12-18