在Nginx中增加对OAuth协议的支持的教程
我们使用nginx的lua中间件建立了oauth2认证和授权层。如果你也有此打算,阅读下面的文档,实现自动化并获得收益。
seatgeek在过去几年中取得了发展,我们已经积累了不少针对各种任务的不同管理接口。我们通常为新的展示需求创建新模块,比如我们自己的博客、图表等。我们还定期开发内部工具来处理诸如部署、可视化操作及事件处理等事务。在处理这些事务中,我们使用了几个不同的接口来认证:
- github/google oauth
- 我们seatgeek内部的用户系统
- 基本认证
- 硬编码登录
显然,实际应用中很不规范。多个认证系统使得难以对用于访问级别和通用许可的各种数据库进行抽象。
单系统认证
我们也做了一些关于如何设置将解决我们问题的研究。这促使了odin的出现,它在验证谷歌应用的用户方面工作的很好。不幸的是它需要使用apache,而我们已和nginx结为连理并把它作为我们的后端应用的前端。
幸运的是,我看了mixlr的博客并引用了他们lua在nginx上的应用:
- 修改响应头
- 重写内部请求
- 选择性地基于ip拒绝主机访问
最后一条看起来很有趣。它开启了软件包管理的地狱之旅。
构建支持lua的nginx
lua for nginx没有被包含在nginx的核心中,我们经常要为osx构建nginx用于开发测试,为linux构建用于部署。
为osx定制nginx
对于osx系统,我推荐使用homebrew进行包管理。它初始的nginx安装包启用的模块不多,这有非常好的理由:
关键在于nginx有着如此之多的选项,如果把它们都加入初始包那一定是疯了,如果我们只把其中一些加入其中就会迫使我们把所有都加入,这会让我们疯掉的。
- charlie sharpsteen, @sharpie
所以我们需要自己构建。合理地构建nginx可以方便我们以后继续扩展。幸运的是,使用homebrew进行包管理十分方便快捷。
我们首先需要一个工作空间:
mkdir -p src
cd src
之后,我们需要找到初始安装信息包。你可以通过下面任何一种方式得到它:
- 找到homebrew_prefix目录,通常在/usr/local下,在其中找到nginx.rb文件
- 从下列地址取得https://raw.github.com/mxcl/homebrew/master/library/formula/nginx.rb
- 使用如下命令 brew cat nginx > nginx.rb
此时如果我们执行brew install ./nginx.rb命令, 它会依据其中的信息安装nginx。既然现在我们要完全定制nginx,我们要重命名信息包,这样之后通过brew update命令进行更新的时候就不会覆盖我们自定义的了:
cat nginx-custom.rb | sed 's/class nginx/class nginxcustom/' >> tmp
rm nginx-custom.rb
mv tmp nginx-custom.rb
我们现在可以将我们需要的模块加入安装信息包中并开始编译了。这很简单,我们只要将所有我们需要的模块以参数形式传给brew install命令,代码如下:
def collect_modules regex=nil
argv.select { |arg| arg.match(regex) != nil }.collect { |arg| arg.gsub(regex, '') }
end
# 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行附近:
add_from_github.each { |name| args << "--add-module=#{fetch_from_github(name)}" }
add_from_mdounin.each { |name| args << "--add-module=#{fetch_from_mdounin(name)}" }
现在我们可以编译我们重新定制的nginx了:
--add-github-module=agentzh/chunkin-nginx-module \
--include-module-http_gzip_static_module \
--add-mdounin-module=ngx_http_auth_request_module#a29d74804ff1
你可以方便地在seatgeek/homebrew-formulae找到以上信息包。
为debian定制nginx
我们通常都会部署到debian的发行版-通常是ubuntu上作为我们的产品服务器。如果是这样,那将会非常简单,运行 dpkg -i nginx-custom 安装我们的定制包。这步骤如此简单你一运行它就完成了。
一些在搜索定制debian/ubuntu包时的笔记:
- 你可以通过 apt-get source package_name来获取debian安装包。
- debian安装包受控于一个 rules文件,你需要sed-fu来操作它。
- 你可以通过编辑 control 文件来更新 deb包的依赖。注意这里指定了一些元依赖(meta-dependency)你不要去删除它,但是这些很容易分辨出来。
- 新的发布必须要在changelog里注明,否则包有可能不会被升级因为它可能已经被安装过了。你需要在表单里使用 +tag_name来指明哪些是你自己在baseline上新加的改动。我会额外加上一个数字-从0开始-指示出包的发布编号。
- 大多数的改动可以以某种方式自动更改,但是似乎没有一个简单的命令行工具可以创建定制的发布包。这也正是我们感兴趣的地方,如果你知道什么的话,请给我们给我们提供一些链接,工具或方法。
在运行这个伟大过程的同时,我构建了一个小的批处理脚本来自动化这个过程的主要步骤,你可以在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模块。
if [ ! -d lua-cjson-2.1.0 ]; then
tar zxf lua-cjson-2.1.0.tar.gz
fi
cd lua-cjson-2.1.0
sed 's/i686/x86_64/' /usr/share/lua/5.1/luarocks/config.lua > /usr/share/lua/5.1/luarocks/config.lua-tmp
rm /usr/share/lua/5.1/luarocks/config.lua
mv /usr/share/lua/5.1/luarocks/config.lua-tmp /usr/share/lua/5.1/luarocks/config.lua
luarocks make
我的oauth提供者类使用了query-string来发送认证的错误信息,我们也需要在我们的lua脚本中为其提供支持:
if args.error and args.error == "access_denied" then
ngx.status = ngx.http_unauthorized
ngx.say("{\"status\": 401, \"message\": \""..args.error_description.."\"}")
return ngx.exit(ngx.http_ok)
end
现在我们解决了基本的错误情况,我们要为访问令牌设置cookie。在我的例子中,cookie会在访问令牌过期前过期,所以我可以利用cookie来刷新访问令牌。
if access_token then
ngx.header["set-cookie"] = "sgaccesstoken="..access_token.."; path=/;max-age=3000"
end
现在,我们解决了错误响应的api,并储存了access_token供后续访问。我们现在需要确保oauth认证过程正确启动。下面,我们想要:
- 如果没有access_token已经或将要存储,开启oauth认证
- 如果query string的参数中有oauth访问代码(access code),使用oauth api检索用户的access_token
- 拒绝使用非法访问代码用户的请求
阅读nginx-lua函数和变量的相关文档可以解决一些问题,或许还能告诉你访问特定请求/响应信息的各种方法。
此时,我们需要从我们的api接口获取一个token。nginx-lua提供了ngx.location.capture方法,支持发起一个内部请求到redis,并接收响应。这意味着,我们不能直接调用类似于http://seatgeek.com/ncaa-football-tickets,但我们可以用proxy_pass把这种外部链接包装成内部请求。
我们通常约定给这样的内部请求前面加一个_(下划线), 用来阻止外部直接访问。
if not access_token or args.code then
if args.code then
-- internal-oauth:1337/access_token
local res = ngx.location.capture("/_access_token?client_id="..app_id.."&client_secret="..app_secret.."&code="..args.code)
-- 终止所有非法请求
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响应。
-- internal-oauth:1337/accessible
local res = ngx.location.capture("/_user", {args = { access_token = access_token } } )
if res.status ~= 200 then
-- 删除损坏的 token
ngx.header["set-cookie"] = "sgaccesstoken=deleted; path=/; expires=thu, 01-jan-1970 00:00:01 gmt"
-- 如果 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,以便用户拥有使用不同的用户身份登录的能力,但这一做法用不用由你决定。
-- ensure we have the minimum for access_level to this resource
if json.access_level < 255 then
-- expire their stored token
ngx.header["set-cookie"] = "sgaccesstoken=deleted; path=/; expires=thu, 01-jan-1970 00:00:01 gmt"
-- 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,如果你有需求的话,就可以用这个取代基本的身份验证,而除此之外的任何事情都是公平的游戏。
ngx.req.set_header("x-user-access-level", json.access_level)
ngx.req.set_header("x-user-email", json.email)
我现在就可以像任何其它的站点那样在我的应用程序中访问这些http头了,而不是用数百行代码和大量的时间来重新实现身份验证。
nginx 和 lua, 放在树结构里面
在这一点上,我们应该有一个可以用来阻挡/拒绝访问的lua脚本。我们可以将这个脚本放到磁盘上的一个文件中,然后使用access_by_lua_file配置来将它应用在我们的nginx站点中。在seatgeek中,我们使用chief来模板化输出配置文件,虽然你可以使用puppet,fabric,或者其它任何你喜欢的工具。
下面是你可以用来使所有东西都运行起来的最简单的nginx的网站。你也可能会想要检查下access.lua - 在这里 - 它是上面的lua脚本编译后的文件。
upstream production-app {
server localhost:8080;
}
# 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;
}
}
}
进一步思考
虽然此设置运行的比较好,但是我想指出一些缺点:
- 上面的代码是我们access_by_lua脚本的简化。我们也处理保存post提交的请求,js加入到到页面更新会话自动处理的令牌更新等,你可能不需要这些功能,而事实上,我不认为我需要它们,直到我们开始了我们在内部系统进行系统测试。
- 我们有一些结点,可以通过一定的后台任务基本认证。这些被修改,数据是从一个外部存储中检索,如s3。注意,这并不总是可能的,所以使用的可能不是你想要的答案。
- oauth2只是我选择的标准。在理论上,你可以使用facebook授权来实现类似的结果。你也可以将这种方法限速,或存储在数据库中的不同的访问级别如在你的lua脚本方便操作和检索使用。如果你真的很无聊,你可以重新实现基本认证在lua,这只需要你。
- 有没有测试控制系统等。测试者会害怕当他们意识到这将是一段时间的集成测试。你可以重新运行上面的嘲笑为全球范围内注入变量以及执行脚本,但它不是理想的设置。
- 你还需要修改应用程序识别你的新的访问标头。内部工具将是最简单的,但你可能需要为供应商软件作出一定的让步。