經過 nginx-lua 給 Nginx 增長 OAuth 支持

前言:咱們使用Nginx的Lua中間件創建了OAuth2認證和受權層。若是你也有此打算,閱讀下面的文檔,實現自動化並得到收益。
SeatGeek 在過去幾年中取得了發展,咱們已經積累了很多針對各類任務的不一樣管理接口。咱們一般爲新的展現需求建立新模塊,好比咱們本身的博客、圖表等。咱們還按期開發內部工具來處理諸如部署、可視化操做及事件處理等事務。在處理這些事務中,咱們使用了幾個不一樣的接口來認證:前端

  • Github/Google Oauth
  • 咱們SeatGeek內部的用戶系統
  • 基本認證
  • 硬編碼登陸

顯然,實際應用中很不規範。多個認證系統使得難以對用於訪問級別和通用許可的各類數據庫進行抽象。nginx

單系統認證

咱們也作了一些關於如何設置將解決咱們問題的研究。這促使了Odin的出現,它在驗證谷歌應用的用戶方面工做的很好。不幸的是它須要使用Apache,而咱們已和Nginx結爲連理並把它做爲咱們的後端應用的前端。
幸運的是,我看了mixlr的博客並引用了他們Lua在Nginx上的應用:git

  • 修改響應頭
  • 重寫內部請求
  • 選擇性地基於IP拒絕主機訪問

最後一條看起來頗有趣。它開啓了軟件包管理的地獄之旅。github

構建支持Lua的Nginx

Lua for Nginx沒有被包含在Nginx的核心中,咱們常常要爲OSX構建Nginx用於開發測試,爲Linux構建用於部署。web

爲OSX定製Nginx

對於OSX系統,我推薦使用Homebrew進行包管理。它初始的Nginx安裝包啓用的模塊很少,這有很是好的理由:redis

關鍵在於NGINX有着如此之多的選項,若是把它們都加入初始包那必定是瘋了,若是咱們只把其中一些加入其中就會迫使咱們把全部都加入,這會讓咱們瘋掉的。
--- Charlie Sharpsteen, @sharpie數據庫

因此咱們須要本身構建。合理地構建Nginx能夠方便咱們之後繼續擴展。幸運的是,使用Homebrew進行包管理十分方便快捷。
咱們首先須要一個工做空間:編程

cd ~
mkdir -p src
cd src

以後,咱們須要找到初始安裝信息包。你能夠經過下面任何一種方式獲得它:json

此時若是咱們執行 brew install ./nginx.rb 命令, 它會依據其中的信息安裝Nginx。既然如今咱們要徹底定製Nginx,咱們要重命名信息包,這樣以後經過 brew update 命令進行更新的時候就不會覆蓋咱們自定義的了:ubuntu

mv nginx.rb nginx-custom.rb
cat nginx-custom.rb | sed 's/class Nginx/class NginxCustom/' >> tmp
rm nginx-custom.rb
mv tmp nginx-custom.rb

咱們如今能夠將咱們須要的模塊加入安裝信息包中並開始編譯了。這很簡單,咱們只要將全部咱們須要的模塊以參數形式傳給 brew install 命令,代碼以下:

# Collects arguments from ARGV
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行附近:

nginx_modules.each { |name| args << "--with-#{name}"; puts "- adding #{name} module" }
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了:

brew install ./nginx-custom.rb \
    --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: gist.github.com/4126937 上找到它。
在我意識到這個過程能夠被腳本化以前僅僅花費了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模塊。

# install 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腳本中爲其提供支持:

local args = ngx.req.get_uri_args()
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來刷新訪問令牌。

local access_token = ngx.var.cookie_SGAccessToken
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 把這種外部連接包裝成內部請求。
咱們一般約定給這樣的內部請求前面加一個_(下劃線), 用來阻止外部直接訪問。

-- 第一步,從api獲取獲取token
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響應。

-- 確保用戶有訪問web應用的權限
-- 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,以便用戶擁有使用不一樣的用戶身份登陸的能力,但這一作法用不用由你決定。

local json = cjson.decode(res.body)
-- 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,若是你有需求的話,就能夠用這個取代基本的身份驗證,而除此以外的任何事情都是公平的遊戲。

-- Set some headers for use within the protected endpoint
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 - 在這裏:gist.github.com/4196901 - 它是上面的lua腳本編譯後的文件。

# The app we are proxying to
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,這隻須要你。
  • 有沒有測試控制系統等。測試者會懼怕當他們意識到這將是一段時間的集成測試。你能夠從新運行上面的嘲笑爲全球範圍內注入變量以及執行腳本,但它不是理想的設置。
  • 你還須要修改應用程序識別你的新的訪問標頭。內部工具將是最簡單的,但你可能須要爲供應商軟件做出必定的讓步。

連接

一些博客中的講解及研究實例

連接

  • SeatGeek Homebrew Formulae with customizable nginx
  • nginx_release.sh for building nginx debs
  • access.lua and nginx-site

另外一些講解閱讀

  • HttpLuaModule
  • proxy_pass, like mod_proxy, but for nginx
  • Lua usage at Mixlr
  • OAuth for Apache
  • Homebrew OS X Package Manager

原文:Yak Shaving: Adding OAuth Support to Nginx via Lua
轉載自:開源中國 - Garfielt, LeoXu, DrZ, BoydWang, 學習者8, FreeZ

相關文章
相關標籤/搜索