Rubyの OmniAuth
gemの用途としてREADMEには
OmniAuth is a library that standardizes multi-provider authentication for web applications. It was created to be powerful, flexible, and do as little as possible. Any developer can create strategies for OmniAuth that can authenticate users via disparate systems. OmniAuth strategies have been created for everything from Facebook to LDAP.
と書かれています。
とはいえ、READMEにあるような認証用途だけではなく、OAuth2.0の認可コードグラントフローをかんたんに扱うためのgemとしても使えそうに感じました。
そこで、「Rails + Devise + OmniAuthにて、Deviseで認証を、OmniAuthでOAuth2.0の認可コードグラントフローの認可だけを扱えるか」試してみたくなったため、調べてみたときのメモを残します。
目次
- 今回のシナリオ
- やらないこと
- 環境
- AS兼RSアプリの実装
- Clientアプリの作成
- 動作確認
- 参考
- ソースコード
今回のシナリオ
OAuth2.0の クライアント
(以降、Client)と 認可サーバ兼リソースサーバ
(以降、AS兼RS)の計2つのRailsアプリを作成します。
作成した2つのRailsアプリを使って、以下のような流れが実現できるかを確認します。
- ClientがDeviseを使い、ユーザーがemailを使ってClientアプリにログイン
- ClientがOmniAuthを使い、OAuth2.0の認可コードグラントフローを開始
- AS兼RSがDeviseを使い、ユーザーがemailを使ってAS兼RSアプリにログイン
- AS兼RSがDoorkeeperを使い、OAuth2.0の認可コードグラントフローに従ってClientへアクセストークンを返却
- ClientがFaraday + アクセストークンを使い、AS兼RSのデータ取得用APIからデータを取得
また、「もし5.の時点でアクセストークンの有効期限切れの場合は、アクセストークンを更新してからデータを取得する」みたいなことも試してみます。
やらないこと
- 本番運用で耐えられるような、細かなバリデーションやユースケース
- 「リフレッシュトークンまわりの挙動」など、今回検証したいことから外れていることは深堀りしない
- そもそも調査用のコードを入れてるので、そのまま本番運用に持っていくことは想定していない
- Deviseでのログイン以外の機能を使うこと
- パスワードリセットやメールアドレス変更は実装しない
- Clientでアクセストークンの有効期限の事前チェック
- 今回は、AS兼RSにアクセストークンを投げることで、有効/無効を確認している
環境
- AS兼RSアプリ
- rails 7.0.2.3
- devise 4.8.1
- doorkeeper 5.5.4
- Clientアプリ
AS兼RSアプリの実装
今回は、以前作ったOIDCのOPと同じようなDevise + Doorkeeperの設定とします。
Railsとdoorkeeper-openid_connectやOmniAuth を使って、OpenID Connectの OpenID Provider と Relying Party を作ってみた - メモ的な思考的な
初期セットアップ
rails new します。
% bundle exec rails new oauth_as_rs --minimal
必要なgemをGemfileに追加します。
gem 'devise' gem 'doorkeeper' group :development do gem 'annotate' end
インストールします。
% bundle install
annotate
をセットアップします。
% bin/rails g annotate:install create lib/tasks/auto_annotate_models.rake
Railsの起動ポートを修正します。
# config/puma.rb port ENV.fetch("PORT") { 3801 }
Deviseのセットアップ
インストールします。
% bin/rails g devise:install create config/initializers/devise.rb create config/locales/devise.en.yml
次にログインユーザーを管理するモデルの作成です。
OIDCの時は user
だったため、今回はDeviseのREADMEにある通り member
を使ってみます。
% bin/rails g devise member invoke active_record create db/migrate/20220329113642_devise_create_members.rb create app/models/member.rb invoke test_unit create test/models/member_test.rb create test/fixtures/members.yml insert app/models/member.rb route devise_for :members
AS兼RSアプリで使うDeviseの機能は
- ユーザー登録
- ログイン
だけなため、自動生成されたマイグレーションファイル db/migrate/<TIMESTAMP>_devise_create_members.rb
を手動で修正します。
# frozen_string_literal: true class DeviseCreateMembers < ActiveRecord::Migration[7.0] def change create_table :members do |t| ## Database authenticatable t.string :email, null: false, default: "" t.string :encrypted_password, null: false, default: "" t.timestamps null: false end add_index :members, :email, unique: true end end
合わせて、 app/models/member.rb
のMemberモデルにあるDeviseの機能も最低限にします。
class Member < ApplicationRecord devise :database_authenticatable, :registerable, :validatable end
doorkeeperのセットアップ
公式ドキュメントに従いセットアップします。
https://doorkeeper.gitbook.io/guides/ruby-on-rails/getting-started
install
% bin/rails generate doorkeeper:install create config/initializers/doorkeeper.rb create config/locales/doorkeeper.en.yml route use_doorkeeper
マイグレーションファイルを作成・調整
% bin/rails generate doorkeeper:migration create db/migrate/20220329114138_create_doorkeeper_tables.rb
作成したdoorkeeper用のマイグレーションファイルの末尾にある以下について、アンコメントした後にDeviseの認証で使用しているテーブル(members
)を指定します。
# Uncomment below to ensure a valid reference to the resource owner's table add_foreign_key :oauth_access_grants, :members, column: :resource_owner_id add_foreign_key :oauth_access_tokens, :members, column: :resource_owner_id
doorkeeperの設定変更
config/initializer/doorkeeper.rb
に対して設定変更を行います。
有効な内容は以下の通りとします。
# frozen_string_literal: true Doorkeeper.configure do orm :active_record resource_owner_authenticator do # ASでの認証にdeviseを使っているため、doorkeeperのドキュメントに従って設定 # https://doorkeeper.gitbook.io/guides/ruby-on-rails/configuration current_member || warden.authenticate!(scope: :member) end # ASのユーザーは、全員OAuth Applicationを操作可能とする admin_authenticator do current_member || warden.authenticate!(scope: :member) end # 認可コードは30秒だけ有効 authorization_code_expires_in 30.seconds # アクセストークンは1分だけ有効 access_token_expires_in 1.minute # リフレッシュトークンを発行するため、古いアクセストークンは無効にする revoke_previous_client_credentials_token # アクセストークンに加え、リフレッシュトークンも発行する use_refresh_token # 今回はOAuth2.0のためscopeは任意の値で良いので適当に `read` とする default_scopes :read # 今回は認可コードグラントフローだけ使う # なお、doorkeeperデフォルトは認可コードグラントフローとクライアントクレデンシャルズグラントフロー grant_flows %w[authorization_code] end
AS兼RSのデータ取得用API向けのモデルを作成
モデル member
に対して外部キーを持つモデル memo
を生成します。
memo
モデルはデータ取得用APIで使います。
% bin/rails generate model memo favorite:string member:references invoke active_record create db/migrate/20220329121510_create_memos.rb create app/models/memo.rb invoke test_unit create test/models/memo_test.rb create test/fixtures/memos.yml
念のため、 app/models/memo.rb
を確認しますが、 member
への belongs_to
が自動で設定されています。
class Memo < ApplicationRecord belongs_to :member end
AS兼RSのデータ取得用APIの作成
コントローラを作成
app/controllers/api/memos_controller.rb
としてコントローラを作成します。
このコントローラでは、Doorkeeperのドキュメントに従い
- アクセストークンがOKであること
before_action :doorkeeper_authorize!
- アクセストークンを元に
member
からアクセスしてきたユーザーを特定すること@member ||= Member.find(doorkeeper_token.resource_owner_id) if doorkeeper_token
を実装します。
Securing the API - doorkeeper
class Api::MemosController < ApplicationController before_action :doorkeeper_authorize! def index unless current_resource_owner return render json: {message: 'deny'} end memo = Memo.find_by(id: current_resource_owner.id) render json: {message: memo ? memo.favorite : 'not found'} end private def current_resource_owner @member ||= Member.find(doorkeeper_token.resource_owner_id) if doorkeeper_token end end
ルーティングの作成
API用のルーティングを config/routes.rb
に追加します。
また、 DoorkeeperやDeviseのroutesも存在するか確認しておきます。
Rails.application.routes.draw do use_doorkeeper devise_for :members namespace :api do resources :memos, only: [:index] end end
データの準備
マイグレーションの実行
% bin/rails db:migrate
seedの作成と実行
AS兼RSで使う member
および memo
用のseedを作成します。
member_foo = Member.new(email: 'foo@example.com', password: 'password') member_foo.save! member_bar = Member.new(email: 'bar@example.com', password: 'password') member_bar.save! Memo.create(favorite: 'ふじ', member: member_foo) Memo.create(favorite: 'シナノゴールド', member: member_bar)
実行します。
% bin/rails db:seed
これでAS兼RSアプリの準備は完了です。
Clientアプリの作成
初期セットアップ
rails new します。
% bundle exec rails new oauth_client --minimal
必要なものをGemfileに追加
# 追加 # omniauthまわり gem 'omniauth' gem 'omniauth-oauth2' # OmniAuthのWikiにある通り、CSRF対策として追加 gem 'omniauth-rails_csrf_protection' # リソースサーバーからデータを取得するために使用 # oauth2 gemの依存として使われているが、明示的に記載 gem 'faraday' # OPからもらった client_id と client_secret を .env ファイルから読み込むために追加 gem 'dotenv-rails', groups: [:development, :test] # modelにスキーマコメントを追加 group :development do gem 'annotate' end
gemをインストールします。
% bundle install
セッションストアをActiveRecordにする
OIDCの時同様、 activerecord-session_store
でセッションストアをActive Record にしたため、READMEに従い設定を追加します。
https://github.com/rails/activerecord-session_store
マイグレーションファイルを作成します。
% bin/rails generate active_record:session_migration
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
起動ポートの修正
クライアントは 3802
で起動します。
# config/puma.rb port ENV.fetch("PORT") { 3802 }
OmniAuthのストラテジーを追加
OmniAuth OAuth2のREADMEに従い、ファイル lib/omniauth/strategies/my_as.rb
に OmniAuth::Strategies::OAuth2
を継承したストラテジーを作成します。
https://github.com/omniauth/omniauth-oauth2
このストラテジーでは、AS兼RSの情報を記載します。
なお、client_optionsの authorize_url
と token_url
はDoorkeeperで自動生成される各エンドポイントを指定します。
require 'omniauth-oauth2' module OmniAuth module Strategies class MyAs < OmniAuth::Strategies::OAuth2 option :name, 'my_as' option :client_options, { site: 'http://localhost:3801', authorize_url: '/oauth/authorize', # 認可エンドポイント token_url: '/oauth/token' # トークンエンドポイント } # scope=read としてリクエスト option :scope, 'read' end end end
OmniAuthのinitializerを追加
OmniAuthのREADMEにある Getting Started に従い、 config/initializers/omniauth.rb
を作成します。
https://github.com/omniauth/omniauth#getting-started
この initializer ではClientアプリの情報を設定します。
require 'omniauth/strategies/my_as' Rails.application.config.middleware.use OmniAuth::Builder do provider :my_as, ENV['CLIENT_ID'], ENV['CLIENT_SECRET'], request_path: '/auth/my_as', callback_path: '/oauth_callbacks' end
なお、 request_path
と callback_path
の違いは以下のとおりです。
request_path
- 認可コードグラントフローを開始するためのパス
- OmniAuthでハンドリングしてくれるため、
config/routes.rb
に記載する必要はない
callback_path
- 認可コードグラントフローのリダイレクトエンドポイント
- OmniAuthでハンドリングしてくれないため、
config/routes.rb
に記載する必要がある
Deviseのセットアップ
Clientでもログインが必要になるため、AS兼RSと同様なセットアップを行います。
インストールします。
% bin/rails g devise:install create config/initializers/devise.rb create config/locales/devise.en.yml
Clientで管理するモデルを作成します。
ASとは別のモデル User
にしてみます
% bin/rails g devise user
マイグレーションもログインできるだけにします。
# db/migrate/<TIMESTAMP>_devise_create_users.rb # frozen_string_literal: true class DeviseCreateUsers < ActiveRecord::Migration[7.0] 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 class User < ApplicationRecord devise :database_authenticatable, :registerable, :validatable end
AS兼RSから渡されたアクセストークンなどを保存するモデルを作成
認可コードグラントフローが終わると、AS兼RSから
などが渡されます。
アクセストークンなどは再利用したいため、今回はDBに保存することにします。
% bin/rails generate model authz access_token:string refresh_token:string expires_at:integer user:references
リダイレクトエンドポイント用のコントローラを作成
リダイレクトエンドポイントで何をするかはClientアプリで実装する必要があります。
app/controllers/oauth_callbacks_controller.rb
として作成し、クレデンシャル情報をモデルに保存する処理を実装します。
class OauthCallbacksController < ApplicationController def index # AS兼RSアプリから受け取ったクレデンシャル情報を取得 credentials = request.env['omniauth.auth'].credentials # クレデンシャル情報をモデルに保存 authz = Authz.find_or_initialize_by(id: current_user.id) do |a| a.user = current_user end authz.update( access_token: credentials.token, refresh_token: credentials.refresh_token, expires_at: credentials.expires_at ) # 処理が終わったらルートにリダイレクト redirect_to root_path end end
AS兼RSアプリから取得した情報を表示するコントローラとビューを作成
今回は
- ログインしているかどうか
- AS兼RSアプリで認可処理をしているかどうか
- AS兼RSアプリからデータを取得できているかどうか
を確認できるようなコントローラとビューを作成します。
コントローラの作成
app/controllers/homes_controller.rb
として作成します。
なお、実装するときにいくつか調べたことをメモします。
Faradayでアクセストークンをリクエストに乗せる方法について
まず、OmniAuthではアクセストークンの取得までしか行わないため、アクセストークンを使ってデータを取得するところは自分で実装する必要があります。
そこで今回はFaraday + アクセストークンを使ってデータを取得するよう実装します。
なお、Faraday 1.x系の場合は、別途 faraday_middleware
というgemを追加して、アクセストークンをリクエストに乗せてあげる必要がありました。
faraday_middleware/oauth.md at main · lostisland/faraday_middleware
ただ、Faraday 2系からはFaradayだけでアクセストークンをリクエストに乗せることができるようになりました。
Authentication Middleware | Faraday
private def fetch_resource_server conn = Faraday.new do |conn| conn.request :authorization, 'Bearer', @access_token end conn.get('http://localhost:3801/api/memos/') end
リフレッシュトークンを使ってアクセストークンを更新する方法について
上記の通り、Faradayではアクセストークンをリクエストに乗せることはできます。
一方、リフレッシュトークンを使ってアクセストークンを更新することはできないようです。
調べてみたところ、 omniauth-oauth2
gemが依存している oauth2
gem を使えば良さそうでした。
- oauth - Refresh token using Omniauth-oauth2 in Rails application - Stack Overflow
- oauth-xx/oauth2: A Ruby wrapper for the OAuth 2.0 protocol.
そこで、以下のようなprivateメソッドを用意して、アクセストークンを更新するようにしてみました。
private def renew_access_token(authz) strategy = OmniAuth::Strategies::MyAs.new( nil, ENV['CLIENT_ID'], ENV['CLIENT_SECRET'], ) client = strategy.client token = OAuth2::AccessToken.new( client, authz.access_token, { refresh_token: authz.refresh_token } ) new_credentials = token.refresh! authz.update( access_token: new_credentials.token, refresh_token: new_credentials.refresh_token, expires_at: new_credentials.expires_at ) @is_renew = true new_credentials.token end
コントローラ全体の実装
エラーハンドリングまわりが甘いですが、今回は試すだけなので気にしないこととします。
class HomesController < ApplicationController before_action :authenticate_user! def index # アクセストークンを取得する authz = Authz.find_by(id: current_user.id) @access_token = authz.present? ? authz.access_token : nil @memos = nil @is_renew = false if @access_token.present? response = fetch_resource_server case response.status when 200 @memos = parse_response_body(response.body) when 401 # アクセストークンが不正の場合、リフレッシュトークンを使って新しいアクセストークンを取得する @access_token = renew_access_token(authz) new_response = fetch_resource_server # アクセストークンをリフレッシュしたとしてもエラーになる可能性はあるが、今回は気にしない @memos = parse_response_body(new_response.body) else # TODO: 本番運用する時は真剣に考えること # エラーっぽいので、エラーメッセージを入れておく @memos = 'エラーです' end end end private def fetch_resource_server conn = Faraday.new do |conn| conn.request :authorization, 'Bearer', @access_token end conn.get('http://localhost:3801/api/memos/') end private def parse_response_body(response_body) JSON.parse(response_body)['message'] end private def renew_access_token(authz) strategy = OmniAuth::Strategies::MyAs.new( nil, ENV['CLIENT_ID'], ENV['CLIENT_SECRET'], ) client = strategy.client token = OAuth2::AccessToken.new( client, authz.access_token, { refresh_token: authz.refresh_token } ) new_credentials = token.refresh! authz.update( access_token: new_credentials.token, refresh_token: new_credentials.refresh_token, expires_at: new_credentials.expires_at ) @is_renew = true new_credentials.token end end
ビューの作成
app/views/homes/index.html.erb
として作成します。
なお、認可処理を開始するためのフォームは、OmniAuthのWikiに従い method
を POST
にしています。
Resolving CVE 2015 9284 · omniauth/omniauth Wiki
<h1>Homes#index</h1> <% if user_signed_in? %> <p><%= current_user.email %>でログインしています</p> <% if @access_token %> <p>初回の認可処理も実施済です</p> <% if @is_renew %> <p>アクセストークンの更新を行いました</p> <% end %> <% else %> <p>認可処理がされていないので、以下のボタンを押して認可処理を行ってください</p> <%= form_tag('/auth/my_as', method: 'post') do %> <button type='submit'>認可処理をする</button> <% end %> <% end %> <% else %> <a href="<%= new_user_session_path %>">ログインする</a> <% end %> <hr> <p>memo: <%= @memos %></p>
ルーティングの作成
AS兼RSアプリから取得した情報を表示するのは root_path
とするため、上記で作成したコントローラとメソッドを指定します。
また、OAuthのリダイレクトエンドポイント用のルートも用意します。
Rails.application.routes.draw do devise_for :users # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html # Defines the root path route ("/") root 'homes#index' # OAuth2.0のリダイレクトエンドポイント用 resources :oauth_callbacks, only: [:index] end
データの準備
マイグレーションの実行
% bin/rails db:migrate
seedの作成と実行
SeedではClientアプリにログインするユーザーを生成します。
なお、AS兼RSでのログイン特別するため、パスワードは別にしています。
user_foo = User.new(email: 'foo@example.com', password: 'client') user_foo.save! user_bar = User.new(email: 'bar@example.com', password: 'client') user_bar.save!
seedを実行します。
% bin/rails db:seed
以上でClientアプリも準備ができました。
動作確認
AS兼RSアプリとClientアプリを起動し、動作を確認します。
AS兼RSアプリにて、OAuthアプリケーションの登録
以下のURLにアクセスし、AS兼RSアプリにClientアプリの情報を登録します。
http://localhost:3801/oauth/applications
登録する内容は以下のとおりです。
項目 | 値 |
---|---|
Name | 任意( my_as など) |
Redirect URI | http://localhost:3802/oauth_callbacks |
Confidential | チェックを入れる |
Scopes | read |
登録後、クライアントIDとクライアントシークレットが表示されます。
Clientアプリの .env ファイルに、クライアントIDとクライアントシークレットを記載
Clientアプリのプロジェクトルートに .env
ファイルを用意して、それぞれ記載します。
CLIENT_ID=*** CLIENT_SECRET=***
全体の動作確認
Clientアプリのルートにアクセスします。
http://localhost:3802/
すると、ログインが必須なため、ログイン画面が表示されます。
ログインすると、認可処理が必要な旨が表示されており、データは表示されていません。そこで、 認可処理をする
ボタンをクリックします。
すると、AS兼RSアプリのログイン画面に遷移しますので、こちらでもログインします。
次は「認可してもよいか」が表示されるため、 Authorize
をクリックします。
すると Clientアプリに戻り、AS兼RSのデータ取得APIから取得したデータが表示されています。
アクセストークンの有効期限は1分ですので、1分以上経過後にリロードすると、アクセストークンを更新した旨も表示されます。
以上より、Rails + Devise + OmniAuthにて、Deviseで認証を、OmniAuthでOAuth2.0の認可コードグラントフローの認可だけを扱えました。
参考
ソースコード
Githubに上げました。
https://github.com/thinkAmi-sandbox/rails_omniauth_without_login-sample