OpenIDConnect+Deviseでの認証クライアントの実装

ソフトウェアエンジニアの彌冨です。
github.com

入社してからもうすぐで2年になろうとしています。
ベンチャーあるあるでいろいろとエンジニア領域外なこともやってきましたが、最近新規サービスをフルスクラッチで作り上げている中で苦労したユーザー認証の話を書きます。

 

前置き

OpenID Connectとは

こちらでは実装の話に集中するため、詳細の話は以下のスライドがわかりやすいので参考にしてください(OpenID Connect自体の話はある程度割愛させていただければと思います)

www.slideshare.net


ただ、端的に私の理解を述べると、OAuth2.0のプロトコルを拡張してシンプルなアイデンティティレイヤーを足すことで、認証と認可の両方を行うことができ、よりセキュアにアクセス管理ができる規格と考えています。


今回は以下のOpenID ConnectのAuthorization Code Flowを実現してログイン時のユーザー認証をANDPAD本体のユーザーアカウント情報を用いて行うフローを実装しました。

f:id:teru-yat:20200625211444p:plain
Authorization Code Flow

RPとはRelying PartyのことでOpenID Connectにおける認証クライアントを示します。この場合は今回の新規サービスのAPIサーバーとなります。
IdPとはOpenID Providerのことで認証サーバーを示し、今回のケースにおいてはANDPAD本体です。
Resource Serverは認証によって保護されたリソースを所有するサーバーで、今回はユーザーアカウントを持つANDPAD本体を指します。

  1. まずエンドユーザーは新規サービスの画面上でログインを行います
  2. ログインボタンを押されると、APIサーバーへログインリクエストを行います
  3. APIサーバーは、認証サーバーであるANDPAD本体のAuthorizationエンドポイントへRedirectします
  4. Redirectに従ってANDPAD本体へアクセスします
  5. ブラウザ上でANDPAD本体でログイン処理を行います
  6. ANDPAD本体は設定してある新規サービスのコールバックURLへAuthorization Codeと一緒にRedirectします
  7. Redirectに従ってAPIサーバーのコールバックURLへアクセスします
  8. APIサーバーはAuthorization Codeを使ってANDPAD本体へID Tokenをリクエストします
  9. ANDPAD本体はAuthorization Codeを確認してID TokenをAPIサーバーへ渡します
  10. APIサーバーはそのID Tokenを使ってANDPAD本体にユーザー情報をリクエストします
  11. ANDPAD本体はユーザー情報を返します
  12. 以上をもってログインが完了し、APIサーバーはログイン結果をブラウザへ返します
  13. ユーザーは無事にログインできました

 

途方にくれた話

さて、新規サービスをフルスクラッチで立ち上げようとしていたわけですが、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では、「幸せを築く人を、幸せに。」をミッションに建築業界のみなさまを支援すべく開発に取り組んでいます。もしよければ以下もご覧ください。

engineer.andpad.co.jp


長文をお読みいただきありがとうございました。