devise-token-authを使用して以下のようなログイン機能を実装する方法についてのまとめ。
- ログイン用ボタンを押下
- 新規タブでプロバイダーの認証画面に遷移
- 認証が完了した後、コールバックにてユーザー登録
- コールバックのウィンドウを閉じて、フロント側に認証情報を渡す
gem
-
devise:
Railsで認証を実装するためのgem - omniauth: マルチプロバイダー認証を行うためのgem
- omniauth-rails-csrf-protection: CVE-2015-9284の脆弱性に対処するためのgem。OmniAuth2.0からはCSRF対策のためプロバイダーの認証画面にはPOSTメソッドを使用してアクセスする。
-
devise-token-auth:
deviseとomniauthを使用してトークンによる認証を実装するためのgem
API側の実装
インストールと修正
gem 'devise_token_auth', git: 'https://github.com/lynndylanhurley/devise_token_auth.git'
gem 'omniauth-rails_csrf_protection'
devise_token_authにdevie及びomniauthが包含されているので、追記する必要はない。また、CSRF対策のためプロバイダーの認証にはPOSTメソッドでアクセスしたいので、23d6b81b1を適用するためにgithubから取得するようにしている('22/2/22)。
bundle install
をしたら
$ rails g devise_token_auth:install User auth
をしてdevise-token-auth用のモデルを生成する。このコマンドで以下の変更が加えられる。
- An initializer will be created at
config/initializers/devise_token_auth.rb
. Read more.- A model will be created in the
app/models
directory. If the model already exists, a concern (and fields forMongoid
) will be included at the file. Read more.- Routes will be appended to file at
config/routes.rb
. Read more.- A concern will be included by your application controller at
app/controllers/application_controller.rb
. Read more.- For
ActiveRecord
a migration file will be created in thedb/migrate
directory. Inspect the migrations file, add additional columns if necessary, and then run the migration:rake db:migrate
apiとして実装するためconfig/routes.rb
を以下に修正してdevise-token-authのコントローラーをネームスペースに収容する。
Rails.application.routes.draw do
namespace :api do
namespace :v1 do
mount_devise_token_auth_for(
'User',
at: 'auth'
)
end
end
end
今回はomniauthによる認証のみを実装するので、deviseのomniauthable
モージュルのみを使用するため、生成されたマイグレーションファイル及びモデルの内容を以下のように修正する。
class DeviseTokenAuthCreateUsers < ActiveRecord::Migration[6.1]
def change
create_table(:users) do |t|
## Required
t.string :provider, null: false
t.string :uid, null: false
## Database authenticatable
# t.string :encrypted_password, :null => false, :default => ""
## Recoverable
# t.string :reset_password_token
# t.datetime :reset_password_sent_at
# t.boolean :allow_password_change, :default => false
## Rememberable
# t.datetime :remember_created_at
## Confirmable
# t.string :confirmation_token
# t.datetime :confirmed_at
# t.datetime :confirmation_sent_at
# t.string :unconfirmed_email # Only if using reconfirmable
## Lockable
# t.integer :failed_attempts, :default => 0, :null => false # Only if lock strategy is :failed_attempts
# t.string :unlock_token # Only if unlock strategy is :email or :both
# t.datetime :locked_at
## User Info
t.string :name
t.string :nickname
t.string :image
t.string :email
t.text :description
## Tokens
t.text :tokens
t.timestamps
end
# add_index :users, :email, unique: true
add_index :users, %i[uid provider], unique: true
# add_index :users, :reset_password_token, unique: true
# add_index :users, :confirmation_token, unique: true
# add_index :users, :unlock_token, unique: true
end
end
モデルには登録の際にAuth Hash Schemaに従ったハッシュが渡され、assing_provider_attrs
メソッドによって、モデルがinfo内の属性を持っていれば代入される。deviseとOmniAuthを使用した登録については以下を参照。
deviseを別途インストールしていないのでuser.rb
にextend Devise::Models
をdevise
の前に追記。
class User < ActiveRecord::Base
# Include default devise modules. Others available are:
# :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
extend Devise::Models
devise :omniauthable
include DeviseTokenAuth::Concerns::User
end
またUserモデルにencrypted_password
をもたせていないので、omniauth_collbacks_controller.rb
及びconfig/routes.rb
を以下のように上書き。
class Api::V1::Auth::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCallbacksController
protected
def handle_new_resource
@oauth_registration = true
# don't set password
# set_random_password
end
end
Rails.application.routes.draw do
namespace :api do
namespace :v1 do
mount_devise_token_auth_for(
'User',
at: 'auth',
controllers: {
omniauth_callbacks: 'api/v1/auth/omniauth_callbacks',
},
)
end
end
end
最後にRailsのCSRF機能オフにするため以下の設定を追記。
module AppName
class Application < Rails::Application
# Don't use csrf tokens
config.action_controller.allow_forgery_protection = false
end
end
プロバイダーの登録
以下を参照。コールバック用のパス(デフォルトの場合/omniauth
)と実際にリクエストするパス(/auth
)が異なるので注意。
ここまでで/api/v1/auth/:provider
にPOSTメソッドでアクセスすれば認証ページにリダイレクト、コールバックによる登録が可能になる。
SPA側の実装
次にSPA側の実装を行う。POSTメソッドでプロバイダー認証画面を新規タブで開くための処理は以下のようになる。authURL(provider)
は{{APIのベースURL}}/api/v1/auth/:provider
を返す関数。
type Provider = 'github'
const openAuth = (provider: Provider): Window | null => {
// open with post method to protect from csrf
let blankForm = document.createElement('form')
blankForm.target = provider
blankForm.method = 'post'
blankForm.action = `${authURL(provider)}?omniauth_window_type=newWindow`
// connect form
blankForm.style.display = 'none'
document.body.appendChild(blankForm)
let authWindow = window.open('', provider)
blankForm.submit()
// cut form
document.body.removeChild(blankForm)
return authWindow
}
新規タブで開くウィンドウ名(window.open
の第2引数)をform.target
の値にすることで、一時的にabout:blank
で開かれたタブにform.submit()
でPOSTメソッドを送信している。window.open
の詳細は以下。
認証用URLにomniauth_window_type=newWindow
を渡すことで、コールバックの際に外部ウィンドウ用のページがdevise-token-authによって返される。
<!DOCTYPE html>
<html>
<head>
<script>
/*
The data is accessible in two ways:
1. Using the postMessage api, this window will respond to a
'message' event with a post of all the data. (This can
be used by browsers other than IE if this window was
opened with window.open())
2. This window has a function called requestCredentials which,
when called, will return the data. (This can be
used if this window was opened in an inAppBrowser using
Cordova / PhoneGap)
*/
var data = JSON.parse(decodeURIComponent('<%= ERB::Util.url_encode( @data.to_json ) %>'));
window.addEventListener("message", function(ev) {
if (ev.data === "requestCredentials") {
ev.source.postMessage(data, '*');
window.close();
}
});
function requestCredentials() {
return data;
}
setTimeout(function() {
document.getElementById('text').innerHTML = (data && data.error) || 'Redirecting...';
}, 1000);
</script>
</head>
<body>
<pre id="text">
</pre>
</body>
</html>
このページにコメントとして記載されている1の方法で、data
にアクセスする。data
には@auth_params
及びuserのハッシュが展開されたオブジェクトが代入されている。
このウィンドウに定期的にpostMessage
を行い、コールバックページから返されたメッセージのe.data
に認証用の情報が含まれていれば情報をクライアント側に保存し、インターバルを抜ける。Reactの場合は以下のようなフックを使用する。postMessage
の詳細は以下。
const [authProvider, setAuthProvider] = useState<Provider | null>(null)
const [openAuthWindow, setOpenAuthWindow] = useState<Window | null>(null)
const { setCurrentUser } = useAuth()
useEffect(() => {
if (!openAuthWindow) return
// See https://github.com/lynndylanhurley/devise_token_auth/blob/master/app/views/devise_token_auth/omniauth_external_window.html.erb
window.addEventListener('message', (e) => {
const data = camelcaseKeys(e.data) as { [key: string]: any }
// See https://github.com/lynndylanhurley/devise_token_auth/blob/23d6b81b14fe39b5e4ce2b0dde897e4abcd850e8/app/controllers/devise_token_auth/omniauth_callbacks_controller.rb#L189
const authParamKeys = ['authToken', 'clientId', 'uid']
if (
!authParamKeys.reduce(
(acc: boolean, cur: string): boolean =>
acc && Object.keys(data).includes(cur),
true
)
) {
return
}
setAuthHeaders({
accessToken: data.authToken,
client: data.clientId,
uid: data.uid,
})
setCurrentUser(data as User)
clearInterval(timer)
setAuthProvider(null)
setOpenAuthWindow(null)
})
const timer = setInterval(() => {
openAuthWindow.postMessage('requestCredentials', domainURL)
}, 200)
}, [openAuthWindow])
devise-token-authについての詳細は以下及びソースコードを参照。