我们使用Nginx的Lua中间件建立了OAuth2认证和授权层。如果你也有此打算,阅读下面的文档,实现自动化并获得收益。
SeatGeek在过去几年中取得了发展,我们已经积累了不少针对各种任务的不同管理接口。我们通常为新的展示需求创建新模块,比如我们自己的博客、图表等。我们还定期开发内部工具来处理诸如部署、可视化操作及事件处理等事务。在处理这些事务中,我们使用了几个不同的接口来认证:
显然,实际应用中很不规范。多个认证系统使得难以对用于访问级别和通用许可的各种数据库进行抽象。
单系统认证
我们也做了一些关于如何设置将解决我们问题的研究。这促使了Odin的出现,它在验证谷歌应用的用户方面工作的很好。不幸的是它需要使用Apache,而我们已和Nginx结为连理并把它作为我们的后端应用的前端。
幸运的是,我看了mixlr的博客并引用了他们Lua在Nginx上的应用:
最后一条看起来很有趣。它开启了软件包管理的地狱之旅。
构建支持Lua的Nginx
Lua for Nginx没有被包含在Nginx的核心中,我们经常要为OSX构建Nginx用于开发测试,为Linux构建用于部署。
为OSX定制Nginx
对于OSX系统,我推荐使用Homebrew进行包管理。它初始的Nginx安装包启用的模块不多,这有非常好的理由:
关键在于NGINX有着如此之多的选项,如果把它们都加入初始包那一定是疯了,如果我们只把其中一些加入其中就会迫使我们把所有都加入,这会让我们疯掉的。
- Charlie Sharpsteen, @sharpie
所以我们需要自己构建。合理地构建Nginx可以方便我们以后继续扩展。幸运的是,使用Homebrew进行包管理十分方便快捷。
我们首先需要一个工作空间:
之后,我们需要找到初始安装信息包。你可以通过下面任何一种方式得到它:
此时如果我们执行brew install ./nginx.rb命令, 它会依据其中的信息安装Nginx。既然现在我们要完全定制Nginx,我们要重命名信息包,这样之后通过brew update命令进行更新的时候就不会覆盖我们自定义的了:
我们现在可以将我们需要的模块加入安装信息包中并开始编译了。这很简单,我们只要将所有我们需要的模块以参数形式传给brew install命令,代码如下:
# Get nginx modules that are not compiled in by default specified in ARGV
def nginx_modules; collect_modules(/^--include-module-/); end
# Get nginx modules that are available on github specified in ARGV
def add_from_github; collect_modules(/^--add-github-module=/); end
# Get nginx modules from mdounin's hg repository specified in ARGV
def add_from_mdounin; collect_modules(/^--add-mdounin-module=/); end
# Retrieve a repository from github
def fetch_from_github name
name, repository = name.split('/')
raise "You must specify a repository name for github modules" if repository.nil?
puts "- adding #{repository} from github..."
`git clone -q git://github.com/#{name}/#{repository} modules/#{name}/#{repository}`
path = Dir.pwd + '/modules/' + name + '/' + repository
end
# Retrieve a tar of a package from mdounin
def fetch_from_mdounin name
name, hash = name.split('#')
raise "You must specify a commit sha for mdounin modules" if hash.nil?
puts "- adding #{name} from mdounin..."
`mkdir -p modules/mdounin && cd $_ ; curl -s -O http://mdounin.ru/hg/#{name}/archive/#{hash}.tar.gz; tar -zxf #{hash}.tar.gz`
path = Dir.pwd + '/modules/mdounin/' + name + '-' + hash
end
上面这个辅助模块可以让我们指定想要的模块并检索模块的地址。现在,我们需要修改nginx-custom.rb文件,使之包含这些模块的名字并在包中检索它们,在58行附近:
现在我们可以编译我们重新定制的nginx了:
你可以方便地在seatgeek/homebrew-formulae找到以上信息包。
为Debian定制Nginx
我们通常都会部署到Debian的发行版-通常是Ubuntu上作为我们的产品服务器。如果是这样,那将会非常简单,运行 dpkg -i nginx-custom 安装我们的定制包。这步骤如此简单你一运行它就完成了。
一些在搜索定制debian/ubuntu包时的笔记:
在运行这个伟大过程的同时,我构建了一个小的批处理脚本来自动化这个过程的主要步骤,你可以在gist on github上找到它。
在我意识到这个过程可以被脚本化之前仅仅花费了90个nginx包的构建时间。
全部OAuth
现在可以测试并部署嵌入Nginx的Lua脚本了,让我们开始Lua编程。
nginx-lua模块提供了一些辅助功能和变量来访问Nginx的绝大多数功能,显然我们可以通过access_by_lua中该模块提供的指令来强制打开OAuth认证。
当使用*_by_lua_file指令后,必须重载nginx来使其起作用。
我用NodeJS为SeatGeek创建了一个简单的OAuth2提供者类。这部分内容很简单,你也很容易获得你是通用语言的响应版本。
接下来,我们的OAuth API使用JSON来处理令牌(token)、访问级别(access level)和重新认证响应(re-authentication responses)。所以我们需要安装lua-cjson模块。
我的OAuth提供者类使用了query-string来发送认证的错误信息,我们也需要在我们的Lua脚本中为其提供支持:
现在我们解决了基本的错误情况,我们要为访问令牌设置cookie。在我的例子中,cookie会在访问令牌过期前过期,所以我可以利用cookie来刷新访问令牌。
现在,我们解决了错误响应的api,并储存了access_token供后续访问。我们现在需要确保OAuth认证过程正确启动。下面,我们想要:
阅读nginx-Lua函数和变量的相关文档可以解决一些问题,或许还能告诉你访问特定请求/响应信息的各种方法。
此时,我们需要从我们的api接口获取一个TOKEN。nginx-lua提供了ngx.location.capture方法,支持发起一个内部请求到redis,并接收响应。这意味着,我们不能直接调用类似于http://seatgeek.com/ncaa-football-tickets,但我们可以用proxy_pass把这种外部链接包装成内部请求。
我们通常约定给这样的内部请求前面加一个_(下划线), 用来阻止外部直接访问。
-- 终止所有非法请求
if res.status ~= 200 then
ngx.status = res.status
ngx.say(res.body)
ngx.exit(ngx.HTTP_OK)
end
-- 解码 token
local text = res.body
local json = cjson.decode(text)
access_token = json.access_token
end
-- cookie 和 proxy_pass token 请求失败
if not access_token then
-- 跟踪用户访问,用于透明的重定向
ngx.header["Set-Cookie"] = "SGRedirectBack="..nginx_uri.."; path=/;Max-Age=120"
-- 重定向到 /oauth , 获取权限
return ngx.redirect("internal-oauth:1337/oauth?client_id="..app_id.."&scope=all")
end
end
此时在Lua脚本中,应该已经有了一个可用的access_token。我们可以用来获取任何请求需要的用户信息。在本文中,返回401表示没有权限,403表示token过期,并且授权信息用简单数字打包成json响应。
-- 如果 token 损坏 ,重定向 403 forbidden 到 oauth
if res.status == 403 then
return ngx.redirect("https://seatgeek.com/oauth?client_id="..app_id.."&scope=all")
end
-- 没有权限
ngx.status = res.status
ngx.say("{"status": 503, "message": "Error accessing api/me for credentials"}")
return ngx.exit(ngx.HTTP_OK)
end
现在,我们已经验证了用户确实是经过身份验证的并且具有某个级别的访问权限,我们可以检查他们的访问级别,看看是否同我们所定义的任何当前端点的访问级别有冲突。我个人在这一步删除了SGAccessToken,以便用户拥有使用不同的用户身份登录的能力,但这一做法用不用由你决定。
-- Disallow access
ngx.status = ngx.HTTP_UNAUTHORIZED
ngx.say("{\"status\": 403, \"message\": \"USER_ID"..json.user_id.." has no access to this resource\"}")
return ngx.exit(ngx.HTTP_OK)
end
-- Store the access_token within a cookie
ngx.header["Set-Cookie"] = "SGAccessToken="..access_token.."; path=/;Max-Age=3000"
-- Support redirection back to your request if necessary
local redirect_back = ngx.var.cookie_SGRedirectBack
if redirect_back then
ngx.header["Set-Cookie"] = "SGRedirectBack=deleted; path=/; Expires=Thu, 01-Jan-1970 00:00:01 GMT"
return ngx.redirect(redirect_back)
end
现在我们只需要通过一些请求头信息告知我们当前的应用谁登录了就行了。您可以重用REMOTE_USER,如果你有需求的话,就可以用这个取代基本的身份验证,而除此之外的任何事情都是公平的游戏。
我现在就可以像任何其它的站点那样在我的应用程序中访问这些http头了,而不是用数百行代码和大量的时间来重新实现身份验证。
Nginx 和 Lua, 放在树结构里面
在这一点上,我们应该有一个可以用来阻挡/拒绝访问的LUA脚本。我们可以将这个脚本放到磁盘上的一个文件中,然后使用access_by_lua_file配置来将它应用在我们的nginx站点中。在SeatGeek中,我们使用Chief来模板化输出配置文件,虽然你可以使用Puppet,Fabric,或者其它任何你喜欢的工具。
下面是你可以用来使所有东西都运行起来的最简单的nginx的网站。你也可能会想要检查下access.lua - 在这里 - 它是上面的lua脚本编译后的文件。
# The internal oauth provider
upstream internal-oauth {
server localhost:1337;
}
server {
listen 80;
server_name private.example.com;
root /apps;
charset utf-8;
# This will run for everything but subrequests
access_by_lua_file "/etc/nginx/access.lua";
# Used in a subrequest
location /_access_token { proxy_pass http://internal-oauth/oauth/access_token; }
location /_user { proxy_pass http://internal-oauth/user; }
location / {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_redirect off;
proxy_max_temp_file_size 0;
if (!-f $request_filename) {
proxy_pass http://production-app;
break;
}
}
}
进一步思考
虽然此设置运行的比较好,但是我想指出一些缺点: