OAuth2やOpenID Connectの理解を深めようと思い、
- OAuth徹底入門 セキュアな認可システムを適用するための原則と実践(Justin Richer Antonio Sanso 須田 智之 Authlete, Inc.)|翔泳社の本
- Auth屋さんの書籍
- OAuth認証とは何か?なぜダメなのか - 2020冬 - r-weblife
- OAuth & OpenID Connect 関連仕様まとめ - Qiita
- OpenID Connect - マイクロソフト系技術情報 Wiki
などを読みました。
読んでいくうちに、OpenID Connectの OpenID Provider
(以降、OP) と Relying Party
(以降、RP)の両方を実装し、動作を確認してみたくなりました。
そこで、最近使ってるRuby + Railsにて、OPとRPを実装してみたときのメモを残します。
なお、今回は動作を確認するのが主な目的なため、OPやRPをイチから作るのではなく、gemを組み合わせて作ることとします。
目次
- 環境
- Open ID Provider (OP)の作成
- Relying Party (RP) の作成
- 実装方針
- OmniAuthのストラテジーについて
- 各種インストール
- Active Record によるセッションストアの設定
- 起動ポートのデフォルト値を修正
- OmniAuthの設定
- OmniAuthのストラテジーを作成
- RPでログインするユーザー用Modelの追加
- マイグレーションを実行
- ログイン・ログアウトを管理するためのControllerを追加
- ApplicationControllerに、現在のユーザーを取得するヘルパーを追加
- OpUserモデルに、AuthHashを元に生成 or 取得するメソッドを作成
- 認証リクエストを送るために、ControllerとViewを追加
- routes.rb を編集
- 動作確認
- セキュリティ対策を追加
- ソースコード
環境
gemなどのバージョン
- macOS
- Open ID Provider
- Relying Party
localhost:3781
で起動- Rails 6.1.4
- omniauth 2.0.4
- omniauth-oauth2 1.7.1
- activerecord-session_store 2.0.0
- セッションストアをActive Record (DB) へと変更
実装すること
- OpenID ConnectのOPとRPをRuby + gem で実装
- OpenID Connectのフローは
認可コードフロー
のみ実装 - IDトークンの検証で使うOPの公開鍵は、OPの公開鍵エンドポイントから動的に取得する
- セキュリティ対策
state
・PKCE
・nonce
- 上記は、使用するgemで実装済かどうかも調べる
また、OpenID Connect フローの概要を OpenID Connect Core 1.0 より引用します。
+--------+ +--------+ | | | | | |---------(1) AuthN Request-------->| | | | | | | | +--------+ | | | | | | | | | | | End- |<--(2) AuthN & AuthZ-->| | | | | User | | | | RP | | | | OP | | | +--------+ | | | | | | | |<--------(3) AuthN Response--------| | | | | | | |---------(4) UserInfo Request----->| | | | | | | |<--------(5) UserInfo Response-----| | | | | | +--------+ +--------+
実装しないこと
- OPの同意画面で「Deny」をクリックしたときの動作
- アクセストークンの JWT 化
- ログアウトまわり
- 例
- RPでログアウトしたら、OPでもログアウトする
- OPでログアウトしたら、RPもログアウトする
- もし実装する場合は、以下を参考にしたい
- 例
- OPの属性を修正したら、RPの属性も同期して修正
- 今回は、ユーザー登録時に UserInfo エンドポイントの内容 (email) を RP へ持ってくるだけ
- そのため、OPで email を変更しても、RPには反映されない
- 今回は、ユーザー登録時に UserInfo エンドポイントの内容 (email) を RP へ持ってくるだけ
- セキュリティ対策のうちの
at_hash
やc_hash
の実装- 今回はバックエンド(Rails)で認可コードフローを使うため、出番がなさそう
- 「OAuth・OIDCへの攻撃と対策を整理して理解できる本」の 「7.6 OIDC認可コードフロー (サーバーサイドアプリ):"code"」より
- 今回はバックエンド(Rails)で認可コードフローを使うため、出番がなさそう
Open ID Provider (OP)の作成
まずは、Open ID Provider から作成します。
OP実装で使用するgemについて
以下のスライドより、Rails + doorkeeper
+ doorkeeper-openid_connect
で作ります。
わかった気になる!OpenID Connect - Speaker Deck
- doorkeeper-gem/doorkeeper: Doorkeeper is an OAuth 2 provider for Ruby on Rails / Grape.
- doorkeeper-gem/doorkeeper-openid_connect: OpenID Connect extension for Doorkeeper
また、ユーザー登録や認証を容易にするため、devise
も使います。
各種インストール
rails new します。
% bundle exec rails new rails_open_id_provider --skip-javascript --skip-turbolinks --skip-sprockets --skip-test
Gemfileに
を追加します。
# 追加 gem 'devise' gem 'doorkeeper' gem 'doorkeeper-openid_connect' group :development do gem 'annotate' end
bundle install
します。
% bundle install ... Post-install message from doorkeeper: Starting from 5.5.0 RC1 Doorkeeper requires client authentication for Resource Owner Password Grant as stated in the OAuth RFC. You have to create a new OAuth client (Doorkeeper::Application) if you didn't have it before and use client credentials in HTTP Basic auth if you previously used this grant flow without client authentication. To opt out of this you could set the "skip_client_authentication_for_password_grant" configuration option to "true", but note that this is in violation of the OAuth spec and represents a security risk. Read https://github.com/doorkeeper-gem/doorkeeper/issues/561#issuecomment-612857163 for more details.
READMEに従い、 annotate
をセットアップします。
https://github.com/ctran/annotate_models#configuration-in-rails
% bin/rails g annotate:install Running via Spring preloader in process 8056 create lib/tasks/auto_annotate_models.rake
起動ポートのデフォルト値を修正
後ほど RP の Railsアプリも作成するため、デフォルトのままでは起動ポート 3000
が重複してしまいます。
そこで、以下に従い config/puma.rb
を修正します。
rails s 時のデフォルトのポート番号を変更する - Qiita
OPは 3780
ポートにしてみます。
port ENV.fetch("PORT") { 3780 }
deviseのセットアップ
installします。
% bin/rails g devise:install Running via Spring preloader in process 7473 create config/initializers/devise.rb create config/locales/devise.en.yml
OPで管理するUserのモデルを作成します。
% bin/rails g devise user Running via Spring preloader in process 7543 invoke active_record create db/migrate/20210812140841_devise_create_users.rb create app/models/user.rb insert app/models/user.rb route devise_for :users
今回使うdeviseの機能は
- ユーザー登録
- ログイン
だけになります。
そこで、マイグレーションファイル db/migrate/<TIMESTAMP>_devise_create_users.rb
を修正し、database authenticatable
だけ有効にします。
class DeviseCreateUsers < ActiveRecord::Migration[6.1] def change create_table :users do |t| ## Database authenticatable t.string :email, null: false, default: "" t.string :encrypted_password, null: false, default: "" t.timestamps null: false end add_index :users, :email, unique: true end end
また、Userモデル (app/models/user.rb
) のdeviseモジュールも修正しておきます。
class User < ApplicationRecord devise :database_authenticatable, :registerable, :validatable end
doorkeeperのセットアップ
devise のセットアップが終わったため、次は doorkeeper のセットアップを行います。
初期化
ドキュメントに従いセットアップします。
https://doorkeeper.gitbook.io/guides/ruby-on-rails/getting-started
まずは install します。
% bin/rails generate doorkeeper:install Running via Spring preloader in process 8136 create config/initializers/doorkeeper.rb create config/locales/doorkeeper.en.yml route use_doorkeeper =============================================================================== There is a setup that you need to do before you can use doorkeeper. Step 1. Go to config/initializers/doorkeeper.rb and configure resource_owner_authenticator block. Step 2. Choose the ORM: If you want to use ActiveRecord run: rails generate doorkeeper:migration And run rake db:migrate Step 3. That's it, that's all. Enjoy! ===============================================================================
続いて、マイグレーションファイルを生成します。
% bin/rails generate doorkeeper:migration Running via Spring preloader in process 8229 create db/migrate/20210812141653_create_doorkeeper_tables.rb
devise の User モデルに合わせるため、マイグレーションファイルの以下の部分を修正します。
# Uncomment below to ensure a valid reference to the resource owner's table add_foreign_key :oauth_access_grants, :users, column: :resource_owner_id add_foreign_key :oauth_access_tokens, :users, column: :resource_owner_id
設定変更
doorkeeper の設定を行うため、 config/initializer/doorkeeper.rb
ファイルを修正します。
今回は以下の設定を行います。
- resource_owner_authenticator
- OPでの認証にdeviseを使っているため、doorkeeperのドキュメントに従って設定
- Configuration - doorkeeper
- admin_authenticator
- 今回はユーザーが全てのOAuth Applicationの操作可能とするため、doorkeeperのドキュメントに従って設定
- Configuration - doorkeeper
- default_scopes
- OpenID Connect を使うため、
openid
- OpenID Connect を使うため、
なお、 skip_authorization
の設定で true
を返すようにすれば、「同意画面を表示せず、自動で同意したことにする」ことも可能です。
https://github.com/doorkeeper-gem/doorkeeper/blob/v5.5.2/lib/generators/doorkeeper/templates/initializer.rb#L426
ただし、RPの認証リクエストのパラメータで prompt
が指定された場合は、 prompt
の値に従った動作となります。
21. プロンプト (prompt) | OAuth & OpenID Connect 関連仕様まとめ - Qiita
prompt
パラメータはこのPRあたりで有効になったようです。
Support other prompt parameters by toupeira · Pull Request #20 · doorkeeper-gem/doorkeeper-openid_connect
他のdoorkeeperの設定については、ソースコードのコメントを読む他、Wikiやドキュメントなどに記載があります。
不要なコメントを削除した設定内容は以下の通りです。
# frozen_string_literal: true Doorkeeper.configure do orm :active_record resource_owner_authenticator do current_user || warden.authenticate!(scope: :user) end admin_authenticator do current_user || warden.authenticate!(scope: :user) end default_scopes :openid end
doorkeeper-openid_connectのセットアップ
初期化
READMEに従い、セットアップします。
https://github.com/doorkeeper-gem/doorkeeper-openid_connect
installします。
% bin/rails g doorkeeper:openid_connect:install Running via Spring preloader in process 8849 create config/initializers/doorkeeper_openid_connect.rb create config/locales/doorkeeper_openid_connect.en.yml route use_doorkeeper_openid_connect
マイグレーションファイルを生成します。
% bin/rails generate doorkeeper:openid_connect:migration Running via Spring preloader in process 8917 create db/migrate/20210812142144_create_doorkeeper_openid_connect_tables.rb
IDトークンの署名で使う鍵を生成
IDトークンの署名で使うための鍵を生成します。
今回は公開鍵方式にするため、以下を参考に RS256
の鍵を生成します。
# rails_open_id_provider ディレクトリの直下で実行 % ssh-keygen -t rsa -P "" -b 4096 -m PEM -f jwtRS256.key Generating public/private rsa key pair. Your identification has been saved in jwtRS256.key. Your public key has been saved in jwtRS256.key.pub.
なお、誤ってcommitしないよう、生成した鍵は .gitignore
に追加しておきます。
設定変更
doorkeeper-openid_connect の設定を行うため、 config/initializer/doorkeeper_openid_connect.rb
ファイルを修正します。
今回は以下の設定を行います。
- issuer
- OPの稼働するホスト名 (
http://localhost:3780
)
- OPの稼働するホスト名 (
- signing_key
- 秘密鍵ファイルを直接読み込む
- claims
- UserInfoエンドポイントで返す claims に
email
を追加
- UserInfoエンドポイントで返す claims に
- 以下の項目はコメントアウトされているものをアンコメント
- resource_owner_from_access_token
- reauthenticate_resource_owner
- subject
不要なコメントを削除した設定内容は以下の通りです。
# frozen_string_literal: true Doorkeeper::OpenidConnect.configure do issuer 'http://localhost:3780' signing_key File.read(Rails.root.join('jwtRS256.key')) subject_types_supported [:public] resource_owner_from_access_token do |access_token| User.find_by(id: access_token.resource_owner_id) end auth_time_from_resource_owner do |resource_owner| end reauthenticate_resource_owner do |resource_owner, return_to| store_location_for resource_owner, return_to sign_out resource_owner redirect_to new_user_session_url end select_account_for_resource_owner do |resource_owner, return_to| end subject do |resource_owner, _application| resource_owner.id end claims do # normal_claimはclaimのalias normal_claim :email, scope: :openid do |resource_owner| resource_owner.email end end end
routeの確認
doorkeeper-openid_connect
により、OpenID Connect 向けのrouteが追加されています。
% bin/rails routes Verb URI Pattern Controller#Action GET /oauth/userinfo(.:format) doorkeeper/openid_connect/userinfo#show POST /oauth/userinfo(.:format) doorkeeper/openid_connect/userinfo#show GET /oauth/discovery/keys(.:format) doorkeeper/openid_connect/discovery#keys GET /.well-known/openid-configuration(.:format) doorkeeper/openid_connect/discovery#provider GET /.well-known/webfinger(.:format) doorkeeper/openid_connect/discovery#webfinger ...
マイグレーション
セキュリティまわりを除き一通りの実装が終わったため、マイグレーションを実行しておきます。
% bin/rake db:migrate ... Annotated (1): app/models/user.rb
Relying Party (RP) の作成
続いて、Relying Party (以下 RP) を作成します。
実装方針
今回のRPの機能は
- OPのユーザー情報を元にログイン
- RPにユーザーがいない場合は、RPのユーザーを作成
- ログアウト
だけです。
そのため、deviseを使わずに、ログイン/ログアウトを実装してみます。
なお、OpenID Connect部分については OmniAuth
を使います。
omniauth/omniauth: OmniAuth is a flexible authentication system utilizing Rack middleware.
ちなみに、OmniAuthの2系を使うため、公式Wikiにて注意点などを確認します。
Upgrading to 2.0 · omniauth/omniauth Wiki
その他のgemとして、
- デバッグ時にセッションの中身を見やすくするため、セッションをActive Recordに保存
activerecord-session_store
- OPから払い出されたクライアントIDとクライアントシークレットをハードコーディングするのではなく、 .env ファイルから読み込む
dotenv-rails
も使います。
OmniAuthのストラテジーについて
OmniAuthのストラテジーの一覧は、公式のWikiに記載されています。
List of Strategies · omniauth/omniauth Wiki
しかし、OpenID Connectのストラテジーは一覧に見当たりませんでした。
調べてみたところ、以下のgemがありました。
https://github.com/m0n9oose/omniauth_openid_connect
ただ、OmniAuthが1.9系でした。2系に対応するPRはありましたが、取り込まれていませんでした。
https://github.com/m0n9oose/omniauth_openid_connect/pull/84
Gitlabでもこのgemを使ってるようでしたが、色々あってGitlab側でforkしたようです。
https://gitlab.com/gitlab-org/gitlab/-/issues/225850
fork先を見てみましたが、色々修正が入っているものの、OmniAuthは1.9系のままでした。
https://gitlab.com/gitlab-org/gitlab-omniauth-openid-connect
そこで、以下の記事やソースコードを参考にしながら、OAuth2のストラテジーにOpenID Connectの機能を追加したものを自作します。
- omniauth/omniauth-oauth2: An abstract OAuth2 strategy for OmniAuth.
- OmniAuth OAuth2 を使って OAuth2 のストラテジーを作るときに知っていると幸せになれるかもしれないこと - Qiita
- OAuth2 に対応した API の OmniAuth Strategy を書いてみる - Qiita
- OIDCでのLINEログインをomniauthに乗っかる形で実装する[Rails]
- nhosoya/omniauth-apple: OmniAuth strategy for Sign In with Apple
各種インストール
rails new します。
% bundle exec rails new rails_relying_party_of_backend --skip-javascript --skip-turbolinks --skip-sprockets --skip-test
Gemfileに以下を追加して bundle install
します。
# 追加 # omniauthまわり gem 'omniauth' gem 'omniauth-oauth2' # OmniAuthのWikiにある通り、CSRF対策として追加 gem 'omniauth-rails_csrf_protection' # デバッグ時にセッションの中身を見やすくするよう、セッションを ActiveRecord で管理するために追加 gem 'activerecord-session_store' # OPからもらった client_id と client_secret を .env ファイルから読み込むために追加 gem 'dotenv-rails', groups: [:development, :test] # modelにスキーマコメントを追加 group :development do gem 'annotate' end
Active Record によるセッションストアの設定
activerecord-session_store
でセッションストアをActive Record にしたため、READMEに従い設定を追加します。
https://github.com/rails/activerecord-session_store
マイグレーションファイルを生成します。
% bin/rails generate active_record:session_migration Running via Spring preloader in process 99042 create db/migrate/20210813010744_add_sessions_table.rb
config/initializer/session_store.rb
を作成して設定を行います。
# ActiveRecordにセッションを保存 Rails.application.config.session_store :active_record_store, :key => '_my_app_session'
セッションストアの中身を分かりやすくするよう、JSON形式でデータを保存するようにします。
config/application.rb
の末尾に以下を追加します。
# 中身を見やすくするよう、JSON形式で保存 ActiveRecord::SessionStore::Session.serializer = :json
起動ポートのデフォルト値を修正
OPと同じくRailsを使うため、RPは 3781
ポートで起動するよう config/puma.rb
を修正します。
port ENV.fetch("PORT") { 3781 }
OmniAuthの設定
OmniAuthのREADMEにある Getting Started に従い config/initializers/omniauth.rb
を作成します。
https://github.com/omniauth/omniauth#getting-started
providerには
- 第1引数
- あとで作成するストラテジーの名称を指定
- 第2引数
- OPより払い出された、クライアントID
- 今回は
.env
ファイルに記載し、dotenv-rails
で読み込む
- 今回は
- OPより払い出された、クライアントID
- 第3引数
- OPより払い出された、クライアントシークレット
- こちらも今回は
.env
ファイルに記載し、dotenv-rails
で読み込む
- こちらも今回は
- OPより払い出された、クライアントシークレット
を指定します。
なお、ストラテジーで設定されている内容を上書きしたい場合は、以下のAuth0のストラテジーのように、引数としてハッシュを渡せば良いようです。
https://github.com/auth0/omniauth-auth0#additional-authentication-parameters
require 'omniauth/strategies/my_op' Rails.application.config.middleware.use OmniAuth::Builder do provider :my_op, ENV['CLIENT_ID_OF_MY_OP'], ENV['CLIENT_SECRET_OF_MY_OP'] # ストラテジーの設定内容を上書きする場合は、以下のように追加しても良い # 参考: https://github.com/auth0/omniauth-auth0#additional-authentication-parameters # { # authorize_params: { # scope: 'openid profile', # prompt: 'none' # } # } end
OmniAuthのストラテジーを作成
OmniAuthの設定で追加した my_op
用のストラテジーを作成します。
OmniAuth OAuth2のREADMEに従い、ファイル lib/omniauth/strategies/my_op.rb
に OmniAuth::Strategies::OAuth2
を継承したストラテジーを作成します。
https://github.com/omniauth/omniauth-oauth2
認証リクエストのパラメータを定義
まずは、認証リクエストのパラメータとして
- name
- ストラテジーの名前なので、
my_op
- ストラテジーの名前なので、
- client_options
- siteで、認証リクエストのエンドポイントを指定
- scope
- OpenID Connectのscopeなので、
openid
- OpenID Connectのscopeなので、
を設定します。
require 'omniauth-oauth2' module OmniAuth module Strategies class MyOp < OmniAuth::Strategies::OAuth2 # Give your strategy a name. option :name, 'my_op' # This is where you pass the options you would pass when # initializing your consumer from the OAuth gem. option :client_options, { site: "#{ENV['OIDC_PROVIDER_HOST']}/oauth/authorize" } # scope=openid としてリクエスト option :scope, 'openid' end end end
トークンエンドポイントから取得したIDトークンの検証とペイロードの取得
続いて、トークンエンドポイントから取得したIDトークンを検証する処理を実装します。
IDトークンは署名付JWT (JWS) のため、
の順で処理します。
Rubyで署名付きJWTの検証を行うgemについて
Rubyで署名付きJWTの検証を行うgemを探したところ、 ruby-jwt
がありました。
jwt/ruby-jwt: A ruby implementation of the RFC 7519 OAuth JSON Web Token (JWT) standard.
また、Gemfile.lockを見ると
oauth2 (1.4.7) ... jwt (>= 1.0, < 3.0) ... omniauth-oauth2 (1.7.1) oauth2 (~> 1.4)
と、 omniauth-oauth2
の依存ですでに jwt
(ruby-jwt) がインストールされていました。
そこで、今回は ruby-jwt
を使ってIDトークンの検証を行います。
IDトークン検証用メソッドの作成
まず、IDトークンの検証用メソッドを作成します。
引数として、
を用意します。
def id_token_payload(id_token, subject_from_userinfo) # ... end
IDトークン検証の方向性 (JWT.decode)
続いて、
を行います。
ruby-jwt
では decode
メソッドを使うことで一括で処理できるようです。
- Expiration Time Claim | jwt/ruby-jwt: A ruby implementation of the RFC 7519 OAuth JSON Web Token (JWT) standard.
- Rubyでjwtを使ったjwt検証でpayloadを取得するまでの流れをざっくり確認してみた | follmy
IDトークンの署名の検証では、OPの公開鍵を使います。
事前のOPの公開鍵をRPへ保存しておくことも考えられますが、今回はIDトークンの署名を検証するたびにOPの公開鍵エンドポイントから取得するようにします。
そのため、 decode
メソッドを
- 第2引数には
nil
を渡す- 固定の公開鍵を使う場合はここで渡せるが、今回は動的に公開鍵を取りに行くため、鍵は渡さない
- encode メソッドのブロックとして、公開鍵を探しに行く処理を実装する
のように使います。
なお、公開鍵のアルゴリズムには OP で公開鍵を作成する時に使った
RSA using SHA-256 hash algorithm
である RS256
を指定します。
ここまでの decode
メソッドの形は
JWT.decode( id_token, # JWT nil, # key: 署名鍵を動的に探すのでnil true, # verify: IDトークンの検証を行う { # options algorithm: 'RS256', # 署名は公開鍵方式なので、RS256を指定 } ) do |jwt_header| # このブロックの中で、OPの公開鍵情報を取得 end
となります。
IDトークンの署名に使った、OPの公開鍵情報を取得
IDトークンの署名鍵として、OPの公開鍵情報を取得します。
OPの公開鍵エンドポイントは /oauth/discovery/keys
です。
このエンドポイントアクセスするために、 oauth2
gemの依存としてインストール済の Faraday
を使います。
なお、公開鍵エンドポイントのレスポンスはJSON形式です。また、公開鍵が1本だけだとしても、
{"keys"=>[ { "kty"=>"RSA", "kid"=>"***", "e"=>"AQAB", "n"=>"***", "use"=>"sig", "alg"=>"RS256" } ] }
のように、キー keys
の中に Array で入っています。
これらを踏まえた実装は以下の通りです。
def fetch_public_keys response = Faraday.get("#{ENV['OIDC_PROVIDER_HOST']}/oauth/discovery/keys") keys = JSON.parse(response.body) keys['keys'] end
IDトークンの署名の検証で使う公開鍵を生成
上記でOPの公開鍵情報は取得できました。
ただ、
- OPの公開鍵そのものは取得できていない
- 取得した公開鍵情報が、IDトークンの署名したときの鍵なのか分からない
のため、公開鍵情報をそのまま使うことができません。
そこで
- OPから取得した公開鍵情報の
kid
とIDトークンのヘッダーのkid
が一致する公開鍵情報で署名したとみなす - 公開鍵情報を元に、公開鍵を作成する
という2つの処理が必要になります。
なお、2.については、 ruby-jwt
の import
メソッドを使うことで、公開鍵情報から公開鍵を取得できるようです。
Ruby RSA from exponent and modulus strings - Stack Overflow
これらを decode
メソッドのブロックに実装します。
JWT.decode( # ... ) do |jwt_header| # このブロックの中で、OPの公開鍵情報を取得 # IDトークンのヘッダーのkidと等しい公開鍵情報を取得 key = fetch_public_keys.find do |k| k['kid'] == jwt_header['kid'] end # 等しいkidが見当たらない場合はエラー raise JWT::VerificationError if key.blank? # 公開鍵の作成 JWT::JWK::RSA.import(key).public_key end
IDトークンの検証
IDトークンの検証については、 decode
メソッドのオプションに verify_iss: true
などと設定することで、関係する各 claim を検証できます。
https://github.com/jwt/ruby-jwt#issuer-claim
IDトークンのペイロードを取得
ここまでの情報と
- id_token
- UserInfoエンドポイントから取得したsubject
より、IDトークンのペイロードを取得するメソッドが完成しました。
メソッドの全体像は以下となります。
def id_token_payload(id_token, subject_from_userinfo) # decodeできない場合はエラーを送出する payload, _header = JWT.decode( id_token, # JWT nil, # key: 署名鍵を動的に探すのでnil https://github.com/jwt/ruby-jwt#finding-a-key true, # verify: IDトークンの検証を行う { # options algorithm: 'RS256', # 署名は公開鍵方式なので、RS256を指定 iss: ENV['ISSUER_OF_MY_OP'], verify_iss: true, aud: ENV['CLIENT_ID_OF_MY_OP'], verify_aud: true, sub: subject_from_userinfo, verify_sub: true, verify_expiration: true, verify_not_before: true, verify_iat: true } ) do |jwt_header| # このブロックの中で、OPの公開鍵情報を取得 # IDトークンのヘッダーのkidと等しい公開鍵情報を取得 key = fetch_public_keys.find do |k| k['kid'] == jwt_header['kid'] end # 等しいkidが見当たらない場合はエラー raise JWT::VerificationError if key.blank? # 公開鍵の作成 # https://stackoverflow.com/a/57402656 JWT::JWK::RSA.import(key).public_key end payload end
OmniAuthの AuthHash に入れる値を定義
OmniAuthでは、認証後の情報を AuthHash に入れます。
- AuthHashの参考資料
今回の場合、AuthHashには
を入れます。
具体的には
- uid
- UserInfoエンドポイントで取得した
sub
- IDトークンと一致することを確認済
- UserInfoエンドポイントで取得した
- info
- UserInfoエンドポイントで取得した
email
- UserInfoエンドポイントで取得した
- extra
を指定します。
class MyOp < OmniAuth::Strategies::OAuth2 uid do raw_info['sub'] end info do { email: raw_info['email'] } end extra do # access_token.params に hash として id_token が入っている # (他に、token_type='Bearer', scope='openid', created_at=<timestamp> が入ってる) id_token = access_token['id_token'] { raw_info: raw_info, id_token_payload: id_token_payload(id_token, raw_info['sub']), id_token: id_token } end def raw_info # raw_infoには、UserInfoエンドポイントから取得できる情報を入れる @raw_info ||= access_token.get("#{ENV['OIDC_PROVIDER_HOST']}/oauth/userinfo").parsed end
RPでログインするユーザー用Modelの追加
OmniAuthにてAuthHashを作成するまでの設定が終わったため、OminAuthを使ってOPからユーザー情報を受け取れるようになりました。
次は、OPのユーザーをRPのユーザーとしてModel OpUser
に保存し、RPのログイン/ログアウトに使用できるようにします。
OpUser
には
- provider
- Userが所属するOP
- IDトークンの
iss
の値
- IDトークンの
- Userが所属するOP
- uid
- IDトークンの
sub
の値
- IDトークンの
- email
- UserInfoエンドポイントで取得する claim に含まれる
email
- ただし、今回はOP Userのemail変更の同期はしないため、OPと値が異なる可能性あり
- UserInfoエンドポイントで取得する claim に含まれる
を項目として用意します。
% bin/rails g model op_user provider uid email Running via Spring preloader in process 968 invoke active_record create db/migrate/20210813014029_create_op_users.rb create app/models/op_user.rb
また、 OpUser
は provider
と uid
で一意になることから、一意制約のindexをマイグレーションファイル <TIMESTAMP>_create_op_users.rb
に追加しておきます。
class CreateOpUsers < ActiveRecord::Migration[6.1] def change create_table :op_users do |t| t.string :provider t.string :uid t.string :email t.timestamps end # 追加 add_index :op_users, %i[provider uid], unique: true end end
マイグレーションを実行
たまっているマイグレーションを実行します。
% bin/rails db:migrate
ログイン・ログアウトを管理するためのControllerを追加
続いて、ログイン・ログアウトを管理するためのControllerとして、 SessionsController
を生成します。
% bin/rails g controller sessions create destroy Running via Spring preloader in process 6477 create app/controllers/sessions_controller.rb route get 'sessions/create' get 'sessions/destroy' invoke erb create app/views/sessions create app/views/sessions/create.html.erb create app/views/sessions/destroy.html.erb invoke helper create app/helpers/sessions_helper.rb invoke assets invoke css create app/assets/stylesheets/sessions.css
ここで、 create
は認証レスポンス後の「リダイレクトURI」のアクションとなります。
また、 create
アクションが呼ばれる前に、OmniAuthが
を行っているため、 create
アクションが呼ばれた時点でユーザーは認証済とみなせます。
そこで、 create
アクションでのログイン処理では、
- セッションIDの更新
- RPではdeviseを使っていないため、自分で実装する必要がある
- 2.7 セッション固定攻撃 - 対応策 | Rails セキュリティガイド - Railsガイド
- AuthHash (
request.env['omniauth.auth']
) を取得- requestオブジェクトについてはこのあたりを参照
OpUser#find_or_create_from_auth_hash!
を実行し、AuthHashの情報から該当するRPのOpUserを取得- このメソッドは後で実装
- AuthHashに含まれる uid をキーに RP のユーザーを検索
- ユーザーを取得できた場合は、そのユーザーでログインしたとみなす
- ユーザーを取得できない場合は、初めて RP を使用するユーザーとみなし、そのユーザーを作成した後にログインしたとみなす
- ログイン中の RP ユーザーのIDを、セッションに保存
を行います。
一方、 destroy
メソッドのログアウト処理では reset_session
するだけです。
以上より、 app/contorllers/sessions_controller.rb
の全体像は以下となります。
class SessionsController < ApplicationController # ログイン情報しかセッションに入れていないため、セッション情報の移し替えは不要 before_action :reset_session def create op_user = OpUser.find_or_create_from_auth_hash!(request.env['omniauth.auth']) session[:user_id] = op_user.id redirect_to root_path, notice: 'ログインしました' end def destroy # 今のところ、OPのセッションはそのまま残る redirect_to root_path, notice: 'ログアウトしました' end end
ApplicationControllerに、現在のユーザーを取得するヘルパーを追加
Viewから現在のユーザー情報を取得できるようにするため、ApplicationControllerにヘルパーメソッド current_user
を追加します。
ruby on rails - What do helper and helper_method do? - Stack Overflow
class ApplicationController < ActionController::Base helper_method :current_user private def current_user # セッションのユーザーIDに紐づくOpUserが存在しない場合は、ログインしていないとみなす @current_user ||= OpUser.find_by(id: session[:user_id]) if session[:user_id] end end
OpUserモデルに、AuthHashを元に生成 or 取得するメソッドを作成
SessionsController#create
から呼ばれるクラスメソッド find_or_create_from_auth_hash!
を作成します。
find_or_create_from_auth_hash!
では、OmniAuthの AuthHash から該当する OpUser を取得します。
なお、AuthHashは以下のような内容です。
{ "provider"=>"my_op", "uid"=>"1", "info"=>#<OmniAuth::AuthHash::InfoHash email="foo@example.com">, "credentials"=>#<OmniAuth::AuthHash expires=true expires_at=1628605105 token="ACCESS_TOKEN">, "extra"=>#<OmniAuth::AuthHash id_token="HEADER.PAYLOAD.SIGNATURE" id_token_payload=#<Hashie::Array [ #<OmniAuth::AuthHash aud="einUOs09X1VB8N9H_ZBo7iVVCRc2Qzc6eRUyiWQQHJU" exp=1628598025 iat=1628597905 iss="http://localhost:3780" sub="1">, #<OmniAuth::AuthHash alg="RS256" kid="Aa28UkVlSquSf4rtBSwDX0XnNda8et8y2OoUZV3EBg0" typ="JWT"> ]> raw_info=#<OmniAuth::AuthHash email="foo@example.com" sub="1" >>}
そこで、 auth_hash
から OpUser モデルに必要な
- provider
- uid
を取り出し、検索 or 作成を行います。
class OpUser < ApplicationRecord def self.find_or_create_from_auth_hash!(auth_hash) provider = auth_hash[:provider] uid = auth_hash[:uid] email = auth_hash[:info][:email] OpUser.find_or_create_by!(provider: provider, uid: uid) do |op_user| op_user.email = email end end end
認証リクエストを送るために、ControllerとViewを追加
今回は OIDC の認証リクエストを送るための画面として、 Home
Viewを用意します。
Home Viewには以下の項目を表示します。
- 未ログインの場合
- ログインボタン
- ログイン済の場合
- ログアウトボタン
- ログインユーザーのemail
- 再度ログインするためのボタン
- 主にデバッグ用途
railsコマンドで生成します。
% bin/rails g controller home index Running via Spring preloader in process 1478 create app/controllers/home_controller.rb route get 'home/index' invoke erb create app/views/home create app/views/home/index.html.erb invoke helper create app/helpers/home_helper.rb invoke assets invoke css create app/assets/stylesheets/home.css
app/views/home/index.html.erb
を編集します。
今回は layouts
は使用せず、1つのViewにすべてを詰め込みます。
そのため、
- OmniAuthのエンドポイント (
/auth/my_op
) へ POST するためのボタンを追加 - ログインしている時は、ログアウトボタンを表示
- flashメッセージを表示
を追加します。
<h1>RP Home</h1> <div> <% if flash[:notice] %> <p> <%= flash[:notice] %> </p> <% end %> <% if current_user %> <p>Logged in as <strong><%= current_user.email %></strong></p> <p><%= link_to 'Logout', logout_path, id: 'logout' %></p> <div>Re Auth <%= form_tag('/auth/my_op', method: 'post') do %> <button type='submit'>Re Login</button> <% end %> </div> <% else %> <%= form_tag('/auth/my_op', method: 'post') do %> <button type='submit'>Login</button> <% end %> <% end %> </div>
routes.rb を編集
ここまででルーティングに必要なものはすべて実装したため、 config/routes.rb
を編集します。
Rails.application.routes.draw do # ルート設定 root to: 'home#index' # OPからのコールバックURI get 'auth/:provider/callback', to: 'sessions#create' # 認証に失敗したときのルーティング get 'auth/failure', to: redirect('/') # ログアウト get 'logout', to: 'sessions#destroy' end
動作確認
ここまでの実装ではセキュリティまわりに不備があるものの、OP・RPが動作するようになったため、動作確認をしてみます。
OPの起動
まずは OP を起動します。
# OPのディレクトリにて % bin/rails s
OPにユーザーを作成
deviseでユーザーを作成します。
以下のURLにアクセスします。
http://localhost:3780/users/sign_up
今回は
項目 | 値 |
---|---|
foo@example.com |
|
Passowrd | password |
で登録します。
OPに、RPのアプリを追加する
doorkeeperの機能を使って、OAuth Applicationを登録します。
ログインした状態で、以下のURLにアクセスします(ログインしていない場合、ログイン画面へリダイレクトします)。 http://localhost:3780/oauth/applications
New Application
をクリックし、以下の内容でアプリケーションを登録します。
項目 | 値 |
---|---|
Name | backend_rp |
Redirect URI | http://localhost:3781/auth/my_op/callback |
Confidential | [x] Application will be used ... |
Scopes | openid |
画面に UID
と Secret
が表示されるため、 RPの .env
ファイルに
キー | 値 |
---|---|
CLIENT_ID_OF_MY_OP | UIDの値 |
CLIENT_SECRET_OF_MY_OP | Secretの値 |
を追加します。
RPの起動
続いて、RPを起動します。
# RPのディレクトリにて % bin/rails s
ログインを試す
RPのルートへアクセスします。 http://localhost:3781/
ログインボタンをクリックすると、OPのログインページへ遷移します。
ログインすると、同意画面が表示されます。
同意すると、RPに戻り、ログイン状態となります。
RPのログアウトボタンをクリックすると、ログアウトします。
RPのOpUserテーブルにも登録されています。
以上より、動作は良さそうです。
セキュリティ対策を追加
ここまでで RP <---> OP 間の動作確認はできましたが、まだセキュリティまわりに不備があります。
そのため、以下で記載されているセキュリティまわり対策の機能を追加していきます。
- 【電子版】OAuth・OIDCへの攻撃と対策を整理して理解できる本(リダイレクトへの攻撃編 - Auth屋 - BOOTH
- OAuth 2.0/OpenID Connectで使われるBindingの仕組みについて整理する - r-weblife
state
state
ですが、RP側の omniauth-oauth2
では実装されています。
例: https://github.com/omniauth/omniauth-oauth2/blob/v1.7.1/lib/omniauth/strategies/oauth2.rb#L63
OP側の doorkeeper
でも実装されています。
例: https://github.com/doorkeeper-gem/doorkeeper/blob/v5.5.2/lib/doorkeeper/oauth/code_response.rb#L25
また、ログを見ても、OP側は認可リクエストを受け取った時に state
も受け取っています。
... Started GET "/oauth/authorize?client_id=***&redirect_uri=***&response_type=code&scope=openid &state=b68d3459455c7842083049f6f793c3f57098488196d5888b" for 127.0.0.1 at 2021-08-13 19:22:21 +0900 ...
RP側も、認可レスポンスの時に code
の他に state
も受け取っています。
Started POST "/auth/my_op" for 127.0.0.1 at 2021-08-13 19:22:18 +0900 Started GET "/auth/my_op/callback?code=*** &state=b68d3459455c7842083049f6f793c3f57098488196d5888b" for 127.0.0.1 at 2021-08-13 19:22:21 +0900 ...
これらより、 state
は OP (doorkeeper) + RP (omniauth-oauth2) であれば、すでに実装されていることが分かりました。
PKCE
RP側のOmniAuthでは、READMEに従い、optionで PKCE の設定が必要です。
https://github.com/omniauth/omniauth-oauth2#creating-an-oauth2-strategy
module OmniAuth module Strategies class MyOp < OmniAuth::Strategies::OAuth2 # ... # PKCEを使うように設定 option :pkce, true # ...
OP側のdoorkeeperでは、Wikiの記載に従い、PKCEを有効にします。
Using PKCE flow · doorkeeper-gem/doorkeeper Wiki
% bin/rails g doorkeeper:pkce Running via Spring preloader in process 20723 create db/migrate/20210813110825_enable_pkce.rb
新しくマイグレーションファイルができたため、マイグレーションを実行します。
% bin/rails db:migrate
設定が終わったため、動作確認をします。
今までと同じくログインした後、OP側のログを確認します。
まずは認証リクエストのパラメータに
code_challenge
code_challenge_method
が追加されています。
Started GET "/oauth/authorize?client_id=*** &code_challenge=E7JbOKJ6DMO7JgsoztSmJdSOIFvRgYanN2BnR6EN0Ns &code_challenge_method=S256 &redirect_uri=***&response_type=code&scope=openid&state=***" for 127.0.0.1 at 2021-08-13 20:10:50 +0900
また、トークンリクエストの際にも code_verifier
が追加されています。
Started POST "/oauth/token" for 127.0.0.1 at 2021-08-13 20:10:50 +0900 Processing by Doorkeeper::TokensController#create as */* Parameters: { "client_id"=>"***", "client_secret"=>"[FILTERED]", "code"=>"[FILTERED]", "code_verifier"=>"34dd455edad41cedcc299fdc91c795f2aa9964fbde2e0b134c732322db62dfaa2094132fdfd5b3d4cfe766e508156345eb910ec6c2240e230faf2835f7e0afc9", "grant_type"=>"authorization_code", "redirect_uri"=>"***"}
これにより、OP (doorkeeper) + RP (omniauth-oauth2) であれば、gemにあるPKCEの設定を追加すれば良いことが分かりました。
nonce
最後に nonce
についてです。
nonce
は OpenID Connect で定義されたパラメータのため、OmniAuth OAuth2 では実装されていません。
そのため、RP側は独自に実装する必要があります。
一方、OP側 (doorkeeper) ですが、過去に doorkeeper 本体にPRが出ていたものの、reject されていました。
- Move hidden fields from view to pre_authorization, for supporting other parameters. by takada-s · Pull Request #420 · doorkeeper-gem/doorkeeper
nonce
claim does not include in ID Token if doorkeeper's default view · Issue #76 · doorkeeper-gem/doorkeeper-openid_connect
そこで、doorkeeper-openid_connect のREADMEを見ると、nonce
に関する記載がありました。
https://github.com/doorkeeper-gem/doorkeeper-openid_connect#nonces
そのため、OP側はdoorkeeper-openid_connect で実装されているようです。
RP側を実装
RP側では
- 認証リクエスト時に
nonce
を生成して、リクエストパラメータに追加- IDトークンの検証時に利用できるよう、生成した
nonce
はsessionに入れておく
- IDトークンの検証時に利用できるよう、生成した
- トークンレスポンスで受け取る IDトークンに
nonce
が含まれるため、IDトークンの検証時の検証を追加するnonce
は一回のみ利用可能なため、IDトークンの検証時にセッションからnonce
を削除する
の実装が必要になります。
なお、上記機能は「認証リクエスト〜トークンレスポンス」の間で必要なため、OmniAuthのストラテジーで実装します。
まずは、 nonce
を生成するメソッドとして generate_nonce
を用意します。
nonce
の仕様を探したところ、以下の記事に記載がありました。ありがとうございます。
OAuth 2.0 / OpenID Connectにおけるstate, nonce, PKCEの限界を意識する - r-weblife
今回はRubyの SecureRandom#urlsafe_base64
を使って、安全な乱数発生器によるURLセーフなbase64文字列を生成します。
https://docs.ruby-lang.org/ja/latest/class/SecureRandom.html#S_URLSAFE_BASE64
また、生成した値はセッションに保存します。
def generate_nonce session['omniauth.nonce'] = SecureRandom.urlsafe_base64 end
次に、認証リクエストのパラメータに nonce
を追加します。
OmniAuth OAuth2 で認証リクエストのパラメータを追加するには、メソッド authorize_params
をオーバーライドすれば良さそうです。
https://github.com/omniauth/omniauth-oauth2/blob/v1.7.1/lib/omniauth/strategies/oauth2.rb#L62
def authorize_params super.tap do |params| params[:nonce] = generate_nonce end end
ここまでで、認証リクエストまわりの実装が終わりました。
続いて、IDトークンの検証時に nonce
も検証する処理を追加します。
まずはセッションから nonce
を取り出すメソッドを用意します。
def nonce_from_session # nonceは再利用できないので、取り出したらsessionから消しておく session.delete('omniauth.nonce') end
次に、IDトークンのペイロードに含まれる nonce
を検証するためのメソッド verify_nonce!
を実装します。
今回のRPは常に nonce
を送信するため
を検証します。
def verify_nonce!(payload) # debug用 nonce = nonce_from_session puts "session ===> #{nonce}" puts "payload ===> #{payload['nonce']}" return if payload['nonce'] && payload['nonce'] == nonce raise JWT::VerificationError end
あとは、IDトークンのペイロードを取得した後に verify_nonce!
メソッドの呼び出しを追加します。
def id_token_payload(id_token, subject_from_userinfo) # decodeできない場合はエラーを送出する payload, _header = JWT.decode( # ... ) do |jwt_header| # ... end # nonceの確認 verify_nonce!(payload) payload end
以上で RP側の実装が終わりました。
OP側を実装
READMEに従って実装します。
https://github.com/doorkeeper-gem/doorkeeper-openid_connect#nonces
doorkeeperのViewをカスタマイズする必要があるため、viewを生成します。
% bin/rails generate doorkeeper:views Running via Spring preloader in process 22964 create app/views/doorkeeper create app/views/doorkeeper/applications/_delete_form.html.erb create app/views/doorkeeper/applications/_form.html.erb create app/views/doorkeeper/applications/edit.html.erb create app/views/doorkeeper/applications/index.html.erb create app/views/doorkeeper/applications/new.html.erb create app/views/doorkeeper/applications/show.html.erb create app/views/doorkeeper/authorizations/error.html.erb create app/views/doorkeeper/authorizations/form_post.html.erb create app/views/doorkeeper/authorizations/new.html.erb create app/views/doorkeeper/authorizations/show.html.erb create app/views/doorkeeper/authorized_applications/_delete_form.html.erb create app/views/doorkeeper/authorized_applications/index.html.erb create app/views/layouts/doorkeeper create app/views/layouts/doorkeeper/admin.html.erb create app/views/layouts/doorkeeper/application.html.erb
生成されたViewのうち、 app/views/doorkeeper/authorizations/new.html.erb
を開き、 nonce
を hidden として追加します。
なお、PKCE用の
- code_challenge
- code_challenge_method
の2つの hidden は削除しないように注意します。
https://github.com/doorkeeper-gem/doorkeeper/wiki/Using-PKCE-flow#enable-pkce-in-doorkeeper
<main role="main"> <p class="h4"> <%= raw t('.prompt', client_name: content_tag(:strong, class: 'text-info') { @pre_auth.client.name }) %> </p> <% if @pre_auth.scopes.count > 0 %> <div id="oauth-permissions"> <p><%= t('.able_to') %>:</p> <ul class="text-info"> <% @pre_auth.scopes.each do |scope| %> <li><%= t scope, scope: [:doorkeeper, :scopes] %></li> <% end %> </ul> </div> <% end %> <div class="actions"> <%= form_tag oauth_authorization_path, method: :post do %> ... <%= hidden_field_tag :nonce, @pre_auth.nonce %> ... <%= form_tag oauth_authorization_path, method: :delete do %> ... <%= hidden_field_tag :nonce, @pre_auth.nonce %> ...
動作確認
ログインが完了した後、OP・RPのログを確認します。
OPのログにて、認証リクエストの際 nonce
が送信されていました。
Started GET "/oauth/authorize?client_id=***&code_challenge=***&code_challenge_method=*** &nonce=PTY0zJhU-Czk7WQ4l7hxjg &redirect_uri=***&response_type=code&scope=openid&state=***" for 127.0.0.1 at 2021-08-13 21:30:44 +0900
また、 RPのログにて、 puts した nonce
が
session ===> PTY0zJhU-Czk7WQ4l7hxjg payload ===> PTY0zJhU-Czk7WQ4l7hxjg
と出力されていました。
これにより、 nonce
の動作を確認できました。
ソースコード
Githubに上げました。
https://github.com/thinkAmi-sandbox/oidc_op_rp-sample
また、 rails new 以降の OP/RPの実装は、以下のPRにまとめました。
https://github.com/thinkAmi-sandbox/oidc_op_rp-sample/pull/1