読者です 読者をやめる 読者になる 読者になる

nginx で omniauth を利用してアクセス制御を行う

インフラストラクチャー部 id:sora_h です。クックパッドでは、社内向けの Web アプリ (以降 “社内ツール”) を社外のネットワークから利用する際、アプリケーションレベルでのアクセス制御とは別に、リバースプロキシでもアクセス制御を実施しています。*1

これまで BASIC 認証あるいは VPN による社内ネットワークを経由した接続という形で許可していました。しかし、iOS の Safari などでは BASIC 認証時のパスワードを保存できない上、頻繁に入力を求められてしまいますし、VPN はリンクを開く前に接続をしておく必要があります。これにより、社内ツールを社外で開く時に手間がかかってしまう問題がありました。

これに対し、一部では typester/gate などを導入し Google Apps での認証を行なっていました。しかしいくつか問題があり、非アドホックな対応では PR を送りつつ独自にパッチを当ててメンテナンスしている状況でした。また、新しい社内ツールで利用する際、新規に設定して起動・監視設定をする必要があるなど、他の社内ツールへ簡単に適用するのが難しい状態でした。

最近になり Microservices 化を進めていく中、社内ツールもあちこちで実装・分離されるようになってきました。一部社内ツールで利用されていた Google Apps 認証を他でも気軽に利用したい、と思い今回 nginx_omniauth_adapter を開発したのでご紹介します。

nginx_omniauth_adapter とは

https://github.com/sorah/nginx_omniauth_adapter

nginx_omniauth_adapter は nginx の ngx_http_auth_request_module と組合せて、Ruby の omniauth gem を利用して nginx のアクセス認証・認可を行うための小さな Rack アプリになっています。omniauth とそのプラグインが持つ豊富な認証手段をそのまま nginx で活用できます。

クックパッドでは基本的にこれを Google OAuth2 と組み合わせて利用しています。ログインされたアカウントが社用の Google Apps アカウントであるのを検証する設定にしています。

使い方

Rack アプリのため、設定は config.ru ファイルで行います。omniauth 側と、nginx_omniauth_adapter 側の設定を config.ru に書いて起動すれば利用できます。参考までに、クックパッドでは Gemfileconfig.ru を置いたリポジトリを作成し、それを capistrano でデプロイしています。

また、GitHub や Google OAuth2 であれば、添付の Dockerfile と config.ru に環境変数を渡して簡単に利用できます。詳細は README をごらんください。

nginx 側の設定例は examples ディレクトリを参考にすると良いでしょう。

仕組み

さて、上記 nginx の設定を見ると、若干トリッキーな内容になっていることが分かると思います。本節ではそれを踏まえて、nginx_omniauth_adapter がどのように ngx_http_auth_request_module と連携しているかを解説します。

ngx_http_auth_request_module とは

まず、肝心の ngx_http_auth_request_module について軽く紹介します。このモジュールは nginx でリクエストは処理させつつ、アクセス認可処理はどこかへ移譲したいという時に利用できます。

具体的には、auth_request directive が設定されているパスへのリクエストを受信した時、クライアントにレスポンスを返す前に nginx が内部リクエストとして auth_request で指定されたパスへリクエストを送信します。 この内部リクエストのレスポンスが 200 であればページが表示できますが、401403 の場合アクセス拒否とみなされます。その時、元のリクエストには 401403 が返答され、リクエストの処理が中断されます。

実際に利用する時は、このモジュールによる認可ができなかった場合、別の設定で認証処理へ遷移させます。このモジュール自体はアクセスを許可するかどうかの判定しかできないためです。詳細は後述します。

auth_request で発生する内部リクエストでは元のリクエストと同じヘッダ・ボディが送信されるので、それを利用して認可を行います。内部リクエスト先に proxy_pass を仕掛けておくことで外部プロセスへ処理を移譲できます。

location / {
  auth_request /_auth/challenge;
}
location = /_auth/challenge {
  internal;
  proxy_pass_request_body off;
  proxy_set_header Content-Length "";
  proxy_set_header Host $http_host;
  proxy_pass http://auth_adapter/test;
}

このモジュールをうまく活用できると、前述の typester/gate や bitly/oauth2_proxy と違い、認証・認可のためのミドルウェアでリバースプロキシを実装する必要がなくなるという点が便利です。

auth_request による認証が失敗した時に認証ページへリダイレクトさせたい

omniauth による認証を開始するためには /auth/… のパスへリダイレクトさせる必要があります。しかし、ngx_http_auth_request_module では auth_request のレスポンスをそのままブラウザに返せません。auth_request 先に届くヘッダーやクッキーで認証や認可を行うことはできますが、失敗時に認証ページへリダイレクトさせるといった事をどうやれば良いのかドキュメントを見てもいまいち分かりません。

そこで、nginx_omniauth_adapter では error_page directive を利用してリダイレクトさせています。error_page directive はステータスコードに応じて内部リクエストを発生させ、それをレスポンスとする事ができます。また、 = オプションを利用すると、その内部リクエストのレスポンスコードを元のエラーのかわりにクライアントへ返答できます。

つまり、auth_request によるアクセス認可が失敗すると、401 あるいは 403 がエラーとしてクライアントに返答されます。これは nginx 自体が送信するエラーページのため、error_page 401 = … を利用して内部リクエストを発生させ、そこでリダイレクトを行ないます。なお、nginx_omniauth_adapter の auth_request 用エンドポイントは、認証がされていないとき 401 を返答します。 *2

location / {
  auth_request /_auth/challenge;
  error_page 401 = /_auth/initiate;
}

location = /_auth/initiate {
  internal;
  proxy_pass_request_body off;
  proxy_set_header Content-Length "";
  proxy_set_header Host $http_host;
  proxy_set_header x-ngx-omniauth-initiate-back-to http://$http_host$request_uri;
  proxy_set_header x-ngx-omniauth-initiate-callback http://$http_host/_auth/callback;
  # ↓が必要な処理を行い、リダイレクトさせる
  proxy_pass http://auth_adapter/initiate;
}

nginx_omniauth_adapter では実際には戻り先 URL 周りの処理があるため、ここでも一度 nginx_omniauth_adapter へ proxy_pass させています。

ただし、この挙動を利用する注意点として proxy_intercept_errors directive は off に設定しておく必要があります。on の場合、proxy_pass でプロキシした先のレスポンスが 401, 403 の時に error_page の設定が作動して、必ず認証ページへ飛ばされてしまうためです。

認証後、元のページへリダイレクトさせる

nginx_omniauth_adapter によってリダイレクトされる先は nginx_omniauth_adapter 自体の FQDN にリダイレクトされ、そこから omniauth の処理が開始されます。これは OAuth 2 のプロバイダなどでコールバック URL が固定だったりするため、nginx_omniauth_adapter 自体に FQDN を割り当てる必要があります。

omniauth による認証処理が終わった後、nginx_omniauth_adapter 側の domain にセッションクッキーがセットされます。元のアプリケーションが動く URL にリダイレクトして戻る必要がありますが、domain が違うためセッションをアプリケーションが動く domain へ引き継ぐ必要があります。

そのため、nginx_omniauth_adapter セッションの中身を認証付き暗号 aes-256-gcm で暗号化してクエリパラメータに載せ元の domain へリダイレクトさせています。 リダイレクト先でも一度 nginx_omniauth_adapter に proxy_pass してもらい、そこで内容を検証・復号した上でアプリ側の domain に再度セッションをセットするようにしています。その後、実際に元のページへのリダイレクトが発生します。元のページを戻った時、正しいセッションクッキーがリクエストに含まれるため、auth_request が成功してリクエストが継続され無事にページが表示されます。

# ここへ戻ってくる
location = /_auth/callback {
  auth_request off;
  proxy_set_header Host $http_host;
  proxy_pass http://auth_adapter/callback;
}

セッションの引き渡しについては、実際のところ Redis, Memcached など KVS をうまく利用してセッションを引き継ぐべきな気がしています。現状の実装だと大きなセッション情報の引き渡しができなかったり、無駄なトラフィックが発生しているためです。今後の改善点の一つになります。

ちなみに、アプリ側の domain で持つセッションは nginx_omniauth_adapter 側 domain のセッションより有効期限を短く設定して、より定期的に検証させるようにしています。nginx_omniauth_adapter 側の認証処理は、nginx_omniauth_adapter 側のセッションが失効していない限りスキップされ、単にアプリ側のセッションを更新するような挙動になります。 これによりセキュリティレベルを低くせず、頻繁に認証ページへリダイレクトされボタンを押す必要をなくしています。

実際のフロー

以上を図に起こすとこのような形になります。箇条書きで順番に解説しているのは README にあります

実際の効果

一部の社内ツールで検証してから、問題なく運用できると判断して、他の社内ツールも (既に typester/gate を利用していた社内ツールを含め) BASIC 認証から置き換えを行いました。社内ではメールのリンクを外で開いた時に煩わしくなくなったと高評価を貰っています。

また、nginx_omniauth_adapter は nginx と同じサーバー上で動作させる必要はなく、かつ、アプリ毎に細かく nginx_omniauth_adapter を設定する必要はありません。 つまり単に nginx の設定だけ挿入すれば利用できるため、nginx さえ導入されていれば新規に何かをインストールして consumer key 等を設定して…という手間なしに omniauth による認証を導入できるので、インフラ側の手間もかなり低くなりました。

FAQ: なぜ社外ネットワークからの直接アクセスを許可しているのか

まず、クックパッドの社内ネットワークに VPN が導入されたのはここ数年での出来事になります。導入以前から BASIC 認証で社外アクセスを許可している社内ツールが存在しました。

では、今現在 VPN が利用できる中、なぜ VPN のみにせず他のアクセス手段を提供しているのか。前述したような VPN 接続の手間を省く利便性もそうですが、クックパッドで現在進められているグローバル展開に関係して子会社の拠点が世界中に存在し、増えている状況にあります。その中で拠点間 VPN、海外で勤務するスタッフ向けの VPN の整備が追い付いていないというのも一つの理由になっています。

また、その代替手段に Google Apps を利用している理由ですが、Google Apps のアカウントは全スタッフに付与されている事と、2 段階認証を全スタッフに対して必須としているためです。下手な BASIC 認証でオープンにするよりも安全だと判断しています。

まとめ

クックパッドの社内ツールで利用されているアクセス制御の仕組み、 nginx_omniauth_adapter を紹介しました。どうぞご利用ください。

*1:アプリケーションレベルでのアクセス制御に加え、アプリケーションの手前で基本的なアクセス制御を実施することになります。これにより万が一アプリケーション側にバグが混入し、インターネットから認証無しで見えるといった事故を防いでいます。

*2:余談ですが、nginx_omniauth_adapter では 403 も利用しています。リクエスト情報とユーザー情報を使いリクエスト毎にアクセス許可するかの判定処理を設定でき、そこで拒否された場合 403 が返され、認証処理への遷移は発生しません。

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/