├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── images └── weixin.png ├── lib └── resty │ └── weauth.lua └── lua-resty-weauth-0.0.3-0.rockspec /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.rock 3 | .vscode 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 K8sCat 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ROCKSPEC = 2 | LUAROCKS_API_KEY = 3 | 4 | luarocks-upload: 5 | luarocks upload $(ROCKSPEC) --api-key=$(LUAROCKS_API_KEY) 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # lua-resty-weauth 2 | 3 | 适用于 OpenResty / ngx_lua 的基于企业微信组织架构的登录认证 4 | 5 | ## 使用 6 | 7 | ### 安装 OpenResty 8 | 9 | 参考: https://k8scat.com/posts/linux/install-openresty-on-ubuntu-from-source-code/ 10 | 11 | ### 下载 12 | 13 | ```bash 14 | cd /usr/local/openresty/site/lualib 15 | git clone https://github.com/k8scat/lua-resty-http.git 16 | git clone https://github.com/k8scat/lua-resty-jwt.git 17 | git clone https://github.com/k8scat/lua-resty-weauth.git 18 | ``` 19 | 20 | ### 配置 21 | 22 | #### http 配置 23 | 24 | ```conf 25 | http { 26 | lua_package_path "/usr/local/openresty/site/lualib/lua-resty-weauth/lib/?.lua;/usr/local/openresty/site/lualib/lua-resty-jwt/lib/?.lua;/usr/local/openresty/site/lualib/lua-resty-jwt/vendor/?.lua;/usr/local/openresty/site/lualib/lua-resty-http/lib/?.lua;;"; 27 | } 28 | ``` 29 | 30 | #### server 配置 31 | 32 | ``` 33 | server { 34 | listen 443 ssl; 35 | server_name weauth.example.com; 36 | resolver 8.8.8.8; # 调用企业微信接口需要设置 DNS 37 | 38 | ssl_certificate /usr/local/openresty/cert/weauth.example.com.crt; 39 | ssl_certificate_key /usr/local/openresty/cert/weauth.example.com.key; 40 | ssl_session_cache shared:SSL:1m; 41 | ssl_session_timeout 5m; 42 | ssl_protocols TLSv1 TLSv1.1 TLSv1.2; 43 | ssl_ciphers AESGCM:HIGH:!aNULL:!MD5; 44 | ssl_prefer_server_ciphers on; 45 | lua_ssl_verify_depth 2; 46 | lua_ssl_trusted_certificate /etc/pki/tls/certs/ca-bundle.crt; 47 | if ($time_iso8601 ~ "^(\d{4})-(\d{2})-(\d{2})T(\d{2})") { 48 | set $year $1; 49 | set $month $2; 50 | set $day $3; 51 | } 52 | access_log logs/weauth.example.com_access_$year$month$day.log main; 53 | error_log logs/weauth.example.com_error_$year$month$day.log; 54 | 55 | access_by_lua_block { 56 | local weauth = require "resty.weauth" 57 | weauth.corp_id = "" 58 | weauth.app_agent_id = "" 59 | weauth.app_secret = "" 60 | weauth.callback_uri = "/weauth_callback" 61 | weauth.logout_uri = "/weauth_logout" 62 | weauth.app_domain = "weauth.example.com" 63 | 64 | weauth.jwt_secret = "thisisjwtsecret" 65 | 66 | weauth.ip_blacklist = {"47.1.2.3"} 67 | weauth.uri_whitelist = {"/"} 68 | weauth.department_whitelist = {1, 2} 69 | 70 | weauth:auth() 71 | } 72 | 73 | location / { 74 | root /data/www; 75 | } 76 | } 77 | 78 | server { 79 | listen 80; 80 | server_name weauth.example.com; 81 | 82 | location / { 83 | rewrite ^/(.*) https://$server_name/$1 redirect; 84 | } 85 | } 86 | ``` 87 | 88 | 配置说明: 89 | 90 | - `corp_id` 用于设置企业 ID 91 | - `app_agent_id` 用于设置企业微信自建应用的 `AgentId` 92 | - `app_secret` 用于设置企业微信自建应用的 `Secret` 93 | - `callback_uri` 用于设置企业微信扫码登录后的回调地址(需设置企业微信授权登录中的授权回调域) 94 | - `logout_uri` 用于设置登出地址 95 | - `app_domain` 用于设置访问域名(需和业务服务的访问域名一致) 96 | - `jwt_secret` 用于设置 JWT secret 97 | - `ip_blacklist` 用于设置 IP 黑名单 98 | - `uri_whitelist` 用于设置地址白名单,例如首页不需要登录认证 99 | - `department_whitelist` 用于设置部门白名单(数字),默认不限制部门 100 | 101 | ## 依赖模块 102 | 103 | - [lua-resty-http](https://github.com/ledgetech/lua-resty-http) 104 | - [lua-resty-jwt](https://github.com/SkyLothar/lua-resty-jwt) 105 | 106 | ## 相关项目 107 | 108 | - [lua-resty-feishu-auth](https://github.com/k8scat/lua-resty-feishu-auth) 适用于 OpenResty / ngx_lua 的基于[飞书](https://www.feishu.cn/)组织架构的登录认证 109 | 110 | ## 作者 111 | 112 | [K8sCat](https://k8scat.com) 113 | 114 | ## 开源协议 115 | 116 | [MIT](./LICENSE) 117 | 118 | ## 交流群 119 | 120 | 交流群 121 | 122 | > 二维码失效可添加微信 「kennn007」,请备注「lua-resty-weauth」。 123 | -------------------------------------------------------------------------------- /images/weixin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/k8scat/lua-resty-weauth/642939f382b33ef104f58fcff7a84a7f82f518e9/images/weixin.png -------------------------------------------------------------------------------- /lib/resty/weauth.lua: -------------------------------------------------------------------------------- 1 | -- Copyright (C) K8sCat 2 | -- https://open.work.weixin.qq.com/api/doc/90000/90135/90664 3 | 4 | local json = require("cjson") 5 | local jwt = require("resty.jwt") 6 | local http = require("resty.http") 7 | local ngx = require("ngx") 8 | 9 | local ok, new_tab = pcall(require, "table.new") 10 | if not ok or type(new_tab) ~= "function" then 11 | new_tab = function (narr, nrec) return {} end 12 | end 13 | 14 | local jwt_header_alg = "HS256" 15 | 16 | local _M = new_tab(0, 30) 17 | 18 | _M._VERSION = "0.0.3" 19 | 20 | _M.corp_id = "" 21 | _M.app_agent_id = "" 22 | _M.app_secret = "" 23 | _M.callback_uri = "/weauth_callback" 24 | _M.app_domain = "" 25 | 26 | _M.jwt_secret = "" 27 | _M.jwt_expire = 28800 -- 8小时 28 | 29 | _M.logout_uri = "/weauth_logout" 30 | _M.logout_redirect = "/" 31 | 32 | _M.cookie_key = "weauth_token" 33 | 34 | _M.ip_blacklist = {} 35 | _M.uri_whitelist = {} 36 | _M.department_whitelist = {} 37 | 38 | local function http_get(url, query) 39 | local request = http.new() 40 | request:set_timeout(10000) 41 | return request:request_uri(url, { 42 | method = "GET", 43 | query = query, 44 | ssl_verify = false 45 | }) 46 | end 47 | 48 | local function has_value(tab, val) 49 | for i=1, #tab do 50 | if tab[i] == val then 51 | return true 52 | end 53 | end 54 | return false 55 | end 56 | 57 | function _M:get_access_token() 58 | local url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken" 59 | local query = { 60 | corpid = self.corp_id, 61 | corpsecret = self.app_secret 62 | } 63 | local res, err = http_get(url, query) 64 | if not res then 65 | return nil, err 66 | end 67 | if res.status ~= 200 then 68 | return nil, res.body 69 | end 70 | local data = json.decode(res.body) 71 | if data["errcode"] ~= 0 then 72 | return nil, res.body 73 | end 74 | return data["access_token"] 75 | end 76 | 77 | function _M:sso() 78 | local callback_url = ngx.var.scheme .. "://" .. self.app_domain .. self.callback_uri 79 | local redirect_url = ngx.var.scheme .. "://" .. self.app_domain .. ngx.var.request_uri 80 | local args = ngx.encode_args({ 81 | appid = self.corp_id, 82 | agentid = self.app_agent_id, 83 | redirect_uri = callback_url, 84 | state = redirect_url 85 | }) 86 | return ngx.redirect("https://open.work.weixin.qq.com/wwopen/sso/qrConnect?" .. args) 87 | end 88 | 89 | function _M:clear_token() 90 | ngx.header["Set-Cookie"] = self.cookie_key .. "=; expires=Thu, 01 Jan 1970 00:00:00 GMT" 91 | end 92 | 93 | function _M:logout() 94 | self:clear_token() 95 | return ngx.redirect(self.logout_redirect) 96 | end 97 | 98 | function _M:get_user_id(access_token, code) 99 | local url = "https://qyapi.weixin.qq.com/cgi-bin/user/getuserinfo" 100 | local query = { 101 | access_token = access_token, 102 | code = code, 103 | } 104 | local res, err = http_get(url, query) 105 | if not res then 106 | return nil, err 107 | end 108 | if res.status ~= 200 then 109 | return nil, res.body 110 | end 111 | local user = json.decode(res.body) 112 | if user["errcode"] ~= 0 then 113 | return nil, res.body 114 | end 115 | return user["UserId"] 116 | end 117 | 118 | function _M:get_user(access_token, user_id) 119 | local url = "https://qyapi.weixin.qq.com/cgi-bin/user/get" 120 | local query = { 121 | access_token = access_token, 122 | userid = user_id 123 | } 124 | ngx.log(ngx.ERR, "get user query: ", json.encode(query)) 125 | local res, err = http_get(url, query) 126 | if not res then 127 | return nil, err 128 | end 129 | if res.status ~= 200 then 130 | return nil, res.body 131 | end 132 | local user = json.decode(res.body) 133 | if user["errcode"] ~= 0 then 134 | return nil, res.body 135 | end 136 | return user 137 | end 138 | 139 | function _M:verify_token() 140 | local token = ngx.var.cookie_weauth_token 141 | if not token then 142 | return nil, "token not found" 143 | end 144 | 145 | local result = jwt:verify(self.jwt_secret, token) 146 | ngx.log(ngx.ERR, "jwt_obj: ", json.encode(result)) 147 | if result["valid"] then 148 | local payload = result["payload"] 149 | if payload["userid"] and payload["department"] then 150 | return payload 151 | end 152 | return nil, "invalid token: " .. json.encode(result) 153 | end 154 | return nil, "invalid token: " .. json.encode(result) 155 | end 156 | 157 | function _M:sign_token(user) 158 | local user_id = user["userid"] 159 | if not user_id or user_id == "" then 160 | return nil, "invalid userid" 161 | end 162 | local department_ids = user["department"] 163 | if not department_ids or type(department_ids) ~= "table" then 164 | return nil, "invalid department" 165 | end 166 | 167 | return jwt:sign( 168 | self.jwt_secret, 169 | { 170 | header = { 171 | typ = "JWT", 172 | alg = jwt_header_alg, 173 | exp = ngx.time() + self.jwt_expire 174 | }, 175 | payload = { 176 | userid = user_id, 177 | department = json.encode(department_ids) 178 | } 179 | } 180 | ) 181 | end 182 | 183 | function _M:check_user_access(user) 184 | if type(self.department_whitelist) ~= "table" then 185 | ngx.log(ngx.ERR, "department_whitelist is not a table") 186 | return false 187 | end 188 | if #self.department_whitelist == 0 then 189 | return true 190 | end 191 | 192 | local department_ids = user["department"] 193 | if not department_ids or department_ids == "" then 194 | return false 195 | end 196 | if type(department_ids) ~= "table" then 197 | department_ids = json.decode(department_ids) 198 | end 199 | for i=1, #department_ids do 200 | if has_value(self.department_whitelist, department_ids[i]) then 201 | return true 202 | end 203 | end 204 | return false 205 | end 206 | 207 | function _M:sso_callback() 208 | local request_args = ngx.req.get_uri_args() 209 | if not request_args then 210 | return ngx.exit(ngx.HTTP_BAD_REQUEST) 211 | end 212 | local code = request_args["code"] 213 | if not code then 214 | return ngx.exit(ngx.HTTP_BAD_REQUEST) 215 | end 216 | ngx.log(ngx.ERR, "sso code: ", code) 217 | 218 | local access_token, err = self:get_access_token() 219 | if not access_token then 220 | ngx.log(ngx.ERR, "get access_token failed: ", err) 221 | return ngx.exit(ngx.HTTP_FORBIDDEN) 222 | end 223 | 224 | local user_id, err = self:get_user_id(access_token, code) 225 | if not user_id then 226 | ngx.log(ngx.ERR, "get user id failed: ", err) 227 | return ngx.exit(ngx.HTTP_FORBIDDEN) 228 | end 229 | ngx.log(ngx.ERR, "user id: ", user_id) 230 | 231 | local user, err = self:get_user(access_token, user_id) 232 | if not user then 233 | ngx.log(ngx.ERR, "get user failed: ", err) 234 | return ngx.exit(ngx.HTTP_FORBIDDEN) 235 | end 236 | ngx.log(ngx.ERR, "login user: ", json.encode(user)) 237 | 238 | if not self:check_user_access(user) then 239 | ngx.log(ngx.ERR, "user access not permitted") 240 | return self:sso() 241 | end 242 | 243 | local token, err = self:sign_token(user) 244 | if not token then 245 | ngx.log(ngx.ERR, "sign token failed: ", err) 246 | return ngx.exit(ngx.HTTP_FORBIDDEN) 247 | end 248 | ngx.header["Set-Cookie"] = self.cookie_key .. "=" .. token 249 | 250 | local redirect_url = request_args["state"] 251 | if not redirect_url or redirect_url == "" then 252 | redirect_url = "/" 253 | end 254 | return ngx.redirect(redirect_url) 255 | end 256 | 257 | function _M:auth() 258 | local request_uri = ngx.var.uri 259 | ngx.log(ngx.ERR, "request uri: ", request_uri) 260 | 261 | if has_value(self.uri_whitelist, request_uri) then 262 | ngx.log(ngx.ERR, "uri in whitelist: ", request_uri) 263 | return 264 | end 265 | 266 | local request_ip = ngx.var.remote_addr 267 | if has_value(self.ip_blacklist, request_ip) then 268 | ngx.log(ngx.ERR, "forbided ip: ", request_ip) 269 | return ngx.exit(ngx.HTTP_FORBIDDEN) 270 | end 271 | 272 | if request_uri == self.logout_uri then 273 | return self:logout() 274 | end 275 | 276 | local payload, err = self:verify_token() 277 | if payload then 278 | if self:check_user_access(payload) then 279 | return 280 | end 281 | 282 | ngx.log(ngx.ERR, "user access not permitted") 283 | self:clear_token() 284 | return self:sso() 285 | end 286 | ngx.log(ngx.ERR, "verify token failed: ", err) 287 | 288 | if request_uri ~= self.callback_uri then 289 | return self:sso() 290 | end 291 | return self:sso_callback() 292 | end 293 | 294 | return _M 295 | -------------------------------------------------------------------------------- /lua-resty-weauth-0.0.3-0.rockspec: -------------------------------------------------------------------------------- 1 | package = "lua-resty-weauth" 2 | version = "0.0.3-0" 3 | source = { 4 | url = "https://github.com/k8scat/lua-resty-weauth", 5 | tag = "v0.0.3" 6 | } 7 | description = { 8 | summary = "适用于 OpenResty / ngx_lua 的基于企业微信组织架构的登录认证", 9 | homepage = "https://github.com/k8scat/lua-resty-weauth", 10 | license = "MIT", 11 | maintainer = "K8sCat " 12 | } 13 | dependencies = { 14 | "lua >= 5.1" 15 | } 16 | build = { 17 | type = "builtin", 18 | modules = { 19 | ["resty.weauth"] = "lib/resty/weauth.lua" 20 | } 21 | } --------------------------------------------------------------------------------