ソフトウェアエンジニアの彌冨です。
github.com
入社してからもうすぐで2年になろうとしています。
ベンチャーあるあるでいろいろとエンジニア領域外なこともやってきましたが、最近新規サービスをフルスクラッチで作り上げている中で苦労したユーザー認証の話を書きます。
前置き
OpenID Connectとは
こちらでは実装の話に集中するため、詳細の話は以下のスライドがわかりやすいので参考にしてください(OpenID Connect自体の話はある程度割愛させていただければと思います)
www.slideshare.net
ただ、端的に私の理解を述べると、OAuth2.0のプロトコルを拡張してシンプルなアイデンティティレイヤーを足すことで、認証と認可の両方を行うことができ、よりセキュアにアクセス管理ができる規格と考えています。
今回は以下のOpenID ConnectのAuthorization Code Flowを実現してログイン時のユーザー認証をANDPAD本体のユーザーアカウント情報を用いて行うフローを実装しました。
RPとはRelying PartyのことでOpenID Connectにおける認証クライアントを示します。この場合は今回の新規サービスのAPIサーバーとなります。
IdPとはOpenID Providerのことで認証サーバーを示し、今回のケースにおいてはANDPAD本体です。
Resource Serverは認証によって保護されたリソースを所有するサーバーで、今回はユーザーアカウントを持つANDPAD本体を指します。
- まずエンドユーザーは新規サービスの画面上でログインを行います
- ログインボタンを押されると、APIサーバーへログインリクエストを行います
- APIサーバーは、認証サーバーであるANDPAD本体のAuthorizationエンドポイントへRedirectします
- Redirectに従ってANDPAD本体へアクセスします
- ブラウザ上でANDPAD本体でログイン処理を行います
- ANDPAD本体は設定してある新規サービスのコールバックURLへAuthorization Codeと一緒にRedirectします
- Redirectに従ってAPIサーバーのコールバックURLへアクセスします
- APIサーバーはAuthorization Codeを使ってANDPAD本体へID Tokenをリクエストします
- ANDPAD本体はAuthorization Codeを確認してID TokenをAPIサーバーへ渡します
- APIサーバーはそのID Tokenを使ってANDPAD本体にユーザー情報をリクエストします
- ANDPAD本体はユーザー情報を返します
- 以上をもってログインが完了し、APIサーバーはログイン結果をブラウザへ返します
- ユーザーは無事にログインできました
途方にくれた話
さて、新規サービスをフルスクラッチで立ち上げようとしていたわけですが、ANDPADのプロダクトの一部であるため、それぞれ別のユーザーアカウントを作らせる仕様は嫌だなと思い、本体ANDPADのユーザーアカウントを利用しようと決めました。
幸い、ANDPAD本体では、OpenID Providerをdoorkeeperを用いて実装されていたので、新規サービスの方ではRelying Partyを実装してAuthorization Code Flowを実現することにしました。
github.com
実際にRelying Partyを実装するにあたり、今後社内で同様に新しいサービスにおいてユーザー認証する時に再利用できる形にしたいとは思いつつ、AWS Cognitoを使って認証を行うかDeviseに認証方式を足すか迷いました。
特に、ローカルでの開発時にAWS Cognitoを用いた場合にどうやって処理を行えるのかが不明で調べたりもしました。
いろいろと調べたりしましたがAWS Cognitoはその時点では私時点の知見が少なく、今後のことを考えるとどこまで再利用性が高いのか等が不明で、結局Deviseに組み込む形で実装を考えることにしました。
ただ、ここからが問題でした。
いろいろと調べたりもしましたが、実装の参考になるドキュメントがなかなか見つからず、また既存のGemがないかも調べましたが継続的にコミットがあり今後も安心して使っていけそうなものは私が探した限りでは見つかりませんでした。
英語も日本語も探しましたがわりとCognitoを使って実装してしまうケースが多いように見受けられました。
結局、Novさんの作られたopenid_connectというGemと、(Implicit FlowでAuthorization Code Flowとは別ですが)Qiitaのこちらの実装例を参考に試行錯誤しながら実装したので紹介させていただこうと思います。
github.com
qiita.com
ちなみに、ローカルでの開発に関してはdocker-composeにAPI Stubサーバーを追加して認証を擬似的に完了するように実装しました。
実装詳細
ある程度概略になりますが、実際に先ほど説明したAuthorization Code Flowの流れを追いながら説明していこうと思います。
また、こちらの実装前に認証サーバー側でOpenID Connect 認証クライアントとしてアプリケーションの登録を行っておいてください。
(前準備)DeviseにOpenIDConnectの認証の登録
Deviseへopen_id_connect_authenticatableを登録しています。
Deviseでは通常userがscopeになりますが今回はこちらの都合でuser_sessionとしています。
config/initializers/devise.rb Devise.setup do |config| config.warden do |manager| manager.default_strategies(scope: :user_session).unshift :open_id_connect_authenticatable end end Devise.add_module(:open_id_connect_authenticatable, { strategy: true, controller: :sessions, model: 'devise/models/open_id_connect_authenticatable', route: :session, })
認証処理の開始
APIのエンドポイントを叩く際に認証がされているかをまず確認します。
app/controllers/application_controller.rb
before_action :authenticate_user_session!
認証がされていない場合認証を開始しますが、認証自体は認証サーバーであるANDPAD本体で行うのでRedirectします。
この時、stateやnonceをCSRF対策やリプレイ攻撃対策としてセッションに保存します。
app/controllers/web/v1/user_sessions/sessions_controller.rb def new redirect_to authorization_uri end def authorization_uri OpenIdConnect::UserAuthorization.new.authorization_uri(set_state, set_nonce) end # CSRF 攻撃対策 def set_state session[:state] = SecureRandom.hex(16) end # リプレイ攻撃対策 def set_nonce session[:nonce] = SecureRandom.hex(16) end
この OpenIdConnect::UserAuthorizationはlib配下に作ったmoduleで、環境変数からANDPAD本体のエンドポイント情報等を使ってAuthorizeエンドポイントを返します。
Client ID, Client SecretはこのAPIサーバーをANDPAD本体側で登録した際に生成されます。
callback URLはapp/controllers/web/v1/user_sessions/sessions_controller.rbのcallbackメソッドに返ってくるように登録しておきます。
lib/utils/open_id_connect/user_authorization.rb module OpenIdConnect class UserAuthorization def authorization_uri(state, nonce) OpenIDConnect::Client.new( identifier: <ANDPAD本体で登録時に生成されたclient id>, secret: <ANDPAD本体で登録時に生成されたclient secret>, host: <ANDPAD本体のhost>, port: <ANDPAD本体のport>, redirect_uri: <ANDPAD本体で登録時に指定したcallback URL>, authorization_endpoint: <ANDPAD本体のAuthorizeエンドポイント>, ).authorization_uri( response_type: 'code', state: state, nonce: nonce, scope: %w[openid] ) end
Authorization Codeを受け取ったら
その後、ANDPAD本体から無事にAuthorization Codeを受け取ったら、ID Tokenを取得してユーザー情報を取得します。
callbackを叩かれたら、Deviseのauthenticate!を呼び出し、無事にユーザー情報を取得できていたら認証を完了します。
app/controllers/web/v1/user_sessions/sessions_controller.rb def callback resource = request.env['warden'].authenticate!(:open_id_connect_authenticatable, scope: :user_session) redirect_to <認証後のトップページURL> end lib/utils/devise/strategies/open_id_connect_authenticatable.rb def authenticate! oidc_client = OpenIdConnect::UserAuthorization.new user_info = oidc_client.verify!(params["code"], session.delete(:nonce)) # user_infoからuser_sessionを紐づけるもしくは登録する処理 success!(user_session) end
authenticate!内では、OpenIdConnect::UserAuthorizationのverify!が呼び出されていますがこの中でID Tokenの取得と検証、そしてユーザー情報の取得を行っています。
verify!ではissやnonceなどの検証を行うフィールドを渡してあげてください。何かしらのエラーがあるとここで例外が投げられるので認証が失敗します。
lib/utils/open_id_connect/user_authorization.rb def verify!(code, nonce) oidc_client = OpenIDConnect::Client.new( identifier: <ANDPAD本体で登録時に生成されたclient id>, host: <ANDPAD本体のhost>, port: <ANDPAD本体のport>, redirect_uri: <ANDPAD本体で登録時に指定したcallback URL>, authorization_endpoint: <ANDPAD本体のAuthorizeエンドポイント>, token_endpoint: <ANDPAD本体のTokenエンドポイント>, userinfo_endpoint: <ANDPAD本体のUserinfoエンドポイント> ) oidc_client.authorization_code = code access_token = oidc_client.access_token! id_token = decode_jwt_string(access_token.id_token) id_token.verify!( # 検証を行うフィールドをここで列挙 ) user_info = access_token.userinfo! user_info end
また、取得したID Tokenはそのままでは暗号化されているので復号化する必要があります。
今回の実装では、Decodeに必要なKeyをJson Web Key(JWK)で取得しています。
ここらへんは先ほど紹介させていただいたQiitaのこちらの記事でDecodeのサンプルがあったので参考にさせていただきました。
qiita.com
lib/utils/open_id_connect/user_authorization.rb def decode_jwt_string(jwt_string) OpenIDConnect::ResponseObject::IdToken.decode(jwt_string, jwk_json) end def jwk_json @jwks ||= JSON.parse( OpenIDConnect.http_client.get_content(<ANDPAD本体のJWKS用エンドポイント>) ).with_indifferent_access JSON::JWK::Set.new @jwks[:keys] end
これで、無事に一連の認証のフローを通してユーザー認証を実現することができました。
ローカル環境でのAPI Stubサーバーを使った認証
次に、補足になりますが、ローカル環境でのAPI Stubサーバーの実装を簡単に紹介しようと思います。
前章まででOpenID Connectの認証クライアントはできたので、今回は認証サーバーのStubを作ります。
ローカルでANDPAD本体も起動しても良いのですが、認証のためだけにANDPAD本体も起動するとさすがにローカル環境が重くなるからです。
まず、Ruby on RailsにStub用のパスを切って以下のようにNginxのDockerfileを作ります。
api-stub/Dockerfile FROM nginx:latest
次に、Nginxの設定ファイルを編集して認証サーバーのAuthorization、Token、UserInfo、JWKのエンドポイントを設定します。
api-stub/conf.d/default.conf # OpenID Connect Authorizationエンドポイント location /v1/open_id_connect/authorize { add_header Access-Control-Allow-Origin *; return http://<host>:<port>/v1/callback?code=<dummy code>&state=<dummy state>; } # OpenID Connect Tokenエンドポイント location /v1/open_id_connect/token/ { add_header Access-Control-Allow-Origin *; error_page 405 =200 $uri; root /usr/share/nginx/json; index token.json; } # OpenID Connect UserInfoエンドポイント location /v1/open_id_connect/userinfo/ { add_header Access-Control-Allow-Origin *; error_page 405 =200 $uri; root /usr/share/nginx/json; index userinfo.json; } # OpenID Connect Decode用Keys location /v1/open_id_connect/keys/ { root /usr/share/nginx/json; index keys.json; }
そのあとは、api-stub/json配下に実際のOpenID Connectで受け取っていたJSON情報を配置して、それぞれのエンドポイントを叩かれた際にJSON情報が返されるようにします。
以上で、ローカル環境でも認証を擬似的に再現して動作するように実装できました。
最後に
今回のOpenID Connectでの認証に関してはなに分時間のない中で手探りで実装したので大変でしたが、無事に実装ができてよかったです。
しかも、認証の大部分はlib配下に置くことができたので、再利用性も高く作れたのかなと思います。
正直、OAuthであればDeviseが対応してあるので楽に実装できたのかなとは思いますが、OAuthでの認証の脆弱性も認知されておりいつまで利用できたかは分からないかなと思います。
もちろん、プロダクトで必要なセキュリティと開発リソースのバランス次第だとは思いますが、今回は同様新規サービス作成時の再利用性を高めるメリットもあったので良いチャレンジになりました。
まとめになりますが、こちらの自身の実装の共有がこれからOpenID Connectの認証を行う方の参考になればと思います。
もしおかしい点があれば暖かい目で見つつご指摘いただけると幸いです。
ANDPADでは、「幸せを築く人を、幸せに。」をミッションに建築業界のみなさまを支援すべく開発に取り組んでいます。もしよければ以下もご覧ください。
長文をお読みいただきありがとうございました。