公司做了个新项目,把设计类的资源和页面放到一个纯前端网站上,方便内部人员查阅和使用。但是在上线以后才发现存在致命问题,大量人员无法访问。
出于安全考虑,设计官网的域名只在内网 DNS 解析,但是业务部门使用的网络并不能访问数据中心内网,导致只有科技部门能使用这个网站。于是要开放公网 DNS 解析,加一层身份验证,确认是公司员工使用。
安排下面的开发做了个后台服务,接入公司统一认证系统,做完一看思路完全错了:这个身份验证是由前端 js 控制的,事实上形同虚设。
制定方案
由于前后端分离架构深入人心,设计官网新增的后台验证服务也是按这个模式做的。但是这就有点生搬硬套了,主流前后端分离架构中需要保护的是用户存储的数据,前端资源只是渲染页面;而在当前需求的场景下,需要保护的反而是前端的资源数据。等到把前端 html、js、css、图片都暴露了再跳到登录页面,已经是数据泄露了。
所以很自然就想到了使用传统的 MVC 架构,把前端的文件都放到后端服务里,后端做一个全局的验证就好了。经过讨论后发现这个方案也实现不了,同事说这个前端项目是基于 node 服务搭建的,改成文件形式需要重新开发,工作量还不小。
无奈之下只能另辟蹊径,最终得出三个方案。
- 用 Java 实现一个简单的 HTTP 代理服务,所有前端资源都用代理转发,在其中加入一个调用第三方鉴权的功能。
- 在 Node 服务器上加一个调用鉴权的功能。
- 在 Nginx 上加一个调用鉴权的功能。
虽然没用过 Nginx+Lua 的方式,但是有了解到这个用法,我觉得这是个通用性很高也很成熟的方案,所以就基于这个方案开始实现。
使用 OpenResty
Nginx 官方是没有 Lua 模块的,所以手动引入 OpenResty 开发的 ngx_http_lua_module 不如直接使用 OpenResty。
OpenResty 是一个基于 Nginx 与 Lua 的高性能 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 页面,再重定向回首页,使用效果如下:
在认证系统登录成功后,跳转到本地的登录页面,从 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 的丰富功能,并应用到项目实践中去。
发表回复