Rails+omniauth-google-oauth2でGoogleログイン(devise無し)
2022/3/31 Rails7で動作するように修正したものを追記しました。
はじめに
ユーザーの個人情報はできるだけ持ちたくないですよね。
ログインの部分をOAuthを利用してGoogleに丸投げして、DBにパスワードを保存しないようにすれば、パスワード漏洩のリスクは大幅に減らすことができます。
この記事では、備忘録のためにRailsでGoogleログイン(だけを)できるようにする導入手順をまとめていきます。
「Rails Googleでログイン」でググると、deviseとomniauth-google-oauth2で実装してるのがたくさん出てきましたが、ZennのようにGoogleでログインしかしないならdeviseを入れるのも大仰な気がするので、今回はdevise無しでやっていきます。
この記事の目標は、Railsで簡単なサンプルアプリを作って最低限Googleでログインできるようにするところまでです。
Ruby/Railsやgemのバージョンは以下の通りです。
- ruby 3.0.1
- Rails 6.1.3.2
- omniauth 2.0.4
- omniauth-google-oauth2 1.0.0
- omniauth-rails_csrf_protection 1.0.0
アプリケーションの準備
1/2. rails new
まずgoogle_login_sampleという名前でRailsアプリを作成します。
$ rails new google_login_sample --skip-test --skip-action-mailer --skip-action-text --skip-action-cable
(今回関係ない機能はskipしました。rails newのオプションについてはドキュメントを参照)
2/2. omniauth-google-oauth2 gemを追加
omniauth-google-oauth2というgemを使います。
+ gem 'omniauth-google-oauth2'
+ gem 'omniauth-rails_csrf_protection'
omniauth-rails_csrf_protectionは最後のほうで入れる必要が出てくるので、今のうちに入れた方が良いです。
$ bundle install
omniauth-google-oauth2をインストールすると、omniauthというgemも追加されます。あとでこのREADMEやwikiも参照します。
omniauth-google-oauth2のREADMEによると、次にGoogle API Setupをしろとのことです。
3. Google APIの設定
https://console.cloud.google.com/apis/dashboard へ行きます。
1/10: プロジェクトを作成をクリック。
(または「プロジェクトの選択」>「新しいプロジェクト」を選択)
2/10: 適当なプロジェクト名をつけて作成。
3/10: メニューの「APIとサービス」>「OAuth同意画面」へ。
4/10: User Typeは「外部」を選択して「作成」。
5/10: アプリ名とメールアドレスを埋めたら「保存して次へ」。
(アプリ名に"Google"が含まれるとエラーになったのでちょっと変えました)
6/10: 「スコープを追加または削除」で「Googleアカウントのメインのメールアドレスの参照」をチェックして「更新」したのち、「保存して次へ」。
7/10: 「+ADD USERS」をクリックして自分のメールアドレスを追加したら、「保存して次へ」。
8/10: 「APIとサービス」のメニューの「認証情報」>「認証情報を作成」>「OAuthクライアントID」を選択。
9/10: アプリケーションの種類を「ウェブアプリケーション」にして、「名前」は適当に
「承認済みのリダイレクトURI」は、http://localhost:3000/auth/google_oauth2/callbackとします。
これは、omniauthのドキュメントに書いてあるまんまの
post '/auth/:provider/callback', to: 'sessions#create'
のようなルーティングに合わせるためです。
「作成」を押すとクライアントIDとクライアントシークレットが出てくるので、コピーしてどこかにメモっておきます。
10/10: 「APIとサービス」のメニューの「ライブラリ」からGoogle+ APIを選択して、「有効にする」をクリック。
以上でGoogle APIの設定は完了です。
4. アプリケーションの中身を記述
1/7. 設定ファイルを作成
omniauth-google-oauth2のドキュメントに戻って、Usageのところを見ます。
config/initializers/omniauth.rbというファイルを作って、クライアントIDとクライアントシークレットを入れろとのことです。ドキュメントではdotenvを使ってるようですが、せっかくなのでcredentialsを使って書くことにします。
(OmniAuth.config.allowed_request_methods = %i[get]を消した理由は最後に出てきます)
Rails.application.config.middleware.use OmniAuth::Builder do
provider :google_oauth2,
Rails.application.credentials.google[:client_id],
Rails.application.credentials.google[:client_secret]
end
$ EDITOR=vim bin/rails credentials:edit -e developmentで中身を以下のように記述します。
google:
client_id: さっきコピーしたクライアントID
client_secret: さっきコピーしたクライアントシークレット
ドキュメントによると、これで/auth/google_oauth2というパスが有効になるとのことです。このパスにPOSTリクエストを送ると、Googleでログインの画面に遷移します。
2/7. ユーザーモデル作成
今回はサンプルアプリで試すだけなのでカラムは最小限にします。
$ bin/rails g model user name email image
$ bin/rails db:migrate
3/7. Controllerを作成してルーティングを設定する
$ bin/rails g controller home index
$ bin/rails g controller sessions
Rails.application.routes.draw do
root 'home#index'
get 'auth/:provider/callback', to: 'sessions#create'
get 'auth/failure', to: redirect('/')
get 'log_out', to: 'sessions#destroy', as: 'log_out'
resources :sessions, only: %i[create destroy]
end
get 'auth/:provider/callback', to: 'sessions#create'の部分は、omniauthのREADMEに書いてあるまんまにしました。
get 'auth/failure', to: redirect('/')の部分は、omniauthのwikiを参考にしています。wikiの下の方に「プロバイダ側でユーザ認証に失敗した場合、OmniAuthはレスポンスをキャッチしてから、/auth/failureにリクエストをリダイレクトする」とあるので、'auth/failure'を受け取ったらトップにリダイレクトするようにしました。
また、ログアウト用に'log_out'のルーティングも追加しました。
4/7. ApplicationControllerにログイン関連メソッドを追加
あとで使うので先に作っておきます。ついでにログインチェックも入れておきます。
(SessionsHelperはRailsチュートリアル風です)
module SessionsHelper
def current_user
return unless (user_id = session[:user_id])
@current_user ||= User.find_by(id: user_id)
end
def log_in(user)
session[:user_id] = user.id
end
def log_out
session.delete(:user_id)
@current_user = nil
end
end
class ApplicationController < ActionController::Base
include SessionsHelper
before_action :check_logged_in
def check_logged_in
return if current_user
redirect_to root_path
end
end
トップページと、ログイン時のSessions#createだけはログインチェックしないでほしいのでskipします。
class HomeController < ApplicationController
+ skip_before_action :check_logged_in, only: :index
class SessionsController < ApplicationController
+ skip_before_action :check_logged_in, only: :create
5/7. SessionsControllerにログイン・ログアウト処理を追加
omniauthのREADMEに書いてある感じを参考にSessionsControllerを書いていきます。(本当ならログイン後はトップページ以外にリダイレクトしたいですね)
google-omniauth-oauth2のREADMEを見ると、request.env['omniauth.auth']でauthentication hashにアクセスできると書いてありますので、privateメソッドで定義しておきます。
class SessionsController < ApplicationController
skip_before_action :check_logged_in, only: :create
def create
if (user = User.find_or_create_from_auth_hash(auth_hash))
log_in user
end
redirect_to root_path
end
def destroy
log_out
redirect_to root_path
end
private
def auth_hash
request.env['omniauth.auth']
end
end
User.find_or_create_from_auth_hash(auth_hash)はこのあと実装します。
6/7. Userモデルにfind_or_create_from_auth_hashメソッド追加
auth_hash、つまりrequest.env['omniauth.auth']にはいろんな情報が入っています。何を取ってくるかはアプリによると思います。
ちなみにfind_or_create_byメソッドは、findで見つからなかった場合のみブロック内の処理を行います。
class User < ApplicationRecord
class << self
def find_or_create_from_auth_hash(auth_hash)
user_params = user_params_from_auth_hash(auth_hash)
find_or_create_by(email: user_params[:email]) do |user|
user.update(user_params)
end
end
private
def user_params_from_auth_hash(auth_hash)
{
name: auth_hash.info.name,
email: auth_hash.info.email,
image: auth_hash.info.image,
}
end
end
end
7/7. Viewを作成、ログインを試す
<!DOCTYPE html>
<html>
<head>
...省略...
</head>
<body>
+ <header>
+ <% if current_user %>
+ <%= image_tag current_user.image %>
+ <%= current_user.name %>さん
+ <%= link_to "ログアウト", log_out_path %>
+ <% else %>
+ ゲストさん
+ <%= link_to "Googleでログイン", "/auth/google_oauth2", method: :post %>
+ <% end %>
+ </header>
<%= yield %>
</body>
</html>
ログインのlink_toにmethod: :postを入れないといけない理由はすぐ後で触れます。
これでログインのリンクをクリックすると、見慣れたGoogleでログインの画面に遷移し、画像も名前も表示されました。(追記: Rails7で試すと、link_toを使うとうまく動作しませんでした!詳細は後述)
この時点でmethod: :postと、omniauth-rails_csrf_protectionのgemを入れていないと、ログにwarningが出ます。
WARN -- omniauth: (google_oauth2) You are using GET as an allowed request method for OmniAuth. This may leave you open to CSRF attacks. As of v2.0.0, OmniAuth by default allows only POST to its own routes. You should review the following resources to guide your mitigation:
(訳: あなたはOmniAuthの許可されたリクエストメソッドとしてGETを使用しています。これでは、CSRF攻撃を受けてしまう可能性があります。v2.0.0の時点で、OmniAuthはデフォルトで独自のルートへのPOSTのみを許可しています。ミティゲーションの指針として、以下のリソースを確認する必要があります。)
https://github.com/omniauth/omniauth/wiki/Resolving-CVE-2015-9284
https://github.com/omniauth/omniauth/issues/960
https://nvd.nist.gov/vuln/detail/CVE-2015-9284
https://github.com/omniauth/omniauth/pull/809
要するにOmniAuthでGETを使うとセキュリティの問題があると怒られているわけです。一番上のリンクを見るとomniauth-rails_csrf_protectionのgemを入れろとあるので、素直に入れます。
このgemはHTTP GETメソッドを使用したOAuthリクエストフェーズへのアクセスを無効にして、デフォルトをPOST-onlyに変更するそうです。
link_toを使うとデフォルトでGETメソッドになるため、もしログインのリンクにmethod: :postを入れていなければ、omniauth-rails_csrf_protectionのgemがGETが無効にしているのでMissing templateのエラーになってしまいます。
そのため、ログインのリンクにはmethod: :postを入れる必要があるというわけでした。
5. 追記: Rails7でログイン画面に遷移しない問題
Rails7にアップデートした上でここまでの手順を試すと、ログインのlink_toでうまく動かなくなっていましたので、追記しておきます。
- ruby 3.1.0
- Rails 7.0.2.3
先に結論を書いておくと
<%= button_to "Googleでログイン", "/auth/google_oauth2", method: :post, data: { turbo: false } %>
でうまくいきました。なぜこれでないといけないのか、以下に書いていきます。
1/2. link_toのオプションのmethod: :postが効かず、Routing Error: No route matches [GET] "/auth/google_oauth2"が出る問題
Rails7ではRailsUJSの代わりにTurboが使われるようになった影響で、link_toのmethodオプションが使えなくなっているようです。
Turboのドキュメントを見ると、
<a href="/articles/54" data-turbo-method="delete">Delete the article</a>
というふうにdata-tubo-method属性が使えると書いてあります。
また、RailsGuidesにはlink_toでの使い方がしれっと書いてありました。
これに倣うと、先ほどのlink_toは以下のようになります.
- <%= link_to "Googleでログイン", "/auth/google_oauth2", method: :post %>
+ <%= link_to "Googleでログイン", "/auth/google_oauth2", data: { turbo_method: :post } %>
一方、link_toとは違って、button_toのmethodオプションはRails7でも健在のようです。こちらの場合は以下のようになります。
- <%= link_to "Googleでログイン", "/auth/google_oauth2", method: :post %>
+ <%= button_to "Googleでログイン", "/auth/google_oauth2", method: :post } %>
どちらのやり方でもGETではなくPOSTリクエストになりました。Routing Error: No route matches [GET] "/auth/google_oauth2"のエラーを直すための選択肢は2つあることになります。
どちらでもいいかと思いきや...?
2/2. 「Googleでログイン」ボタンを押すとCORSのエラーが出る問題
上記2つの実装どちらも、「Googleでログイン」ボタンを押してもGoogleログイン画面に遷移せず、開発者ツールのログに以下のようなエラーが出ます。
Access to fetch at 'https ://accounts.google .com/o/oauth2/auth?access_type=offline&client_id=xxxxxxxx.apps.googleusercontent.com&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fauth%2Fgoogle_oauth2%2Fcallback&response_type=code&scope=email+profile&state=xxxxxxxxxxxx' (redirected from 'htt p://localhost:3000/auth/google_oauth2') from origin 'htt p://localhost:3000' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.
Access-Control-Allow-Originヘッダーが要求されたリソースに存在しない??いろいろ調べてrack-corsgemを試したりしましたが、全く変化がありません。
結局、こちらの記事に答えを見つけました。ログインボタンではTurboをオフにせよとのことです。
これら↓
のissueで言われているように、
data-turbo=falseをつけるやり方があるようです。
この書き方で、最終的にうまくいきました。
- <%= link_to "Googleでログイン", "/auth/google_oauth2", method: :post %>
+ <%= button_to "Googleでログイン", "/auth/google_oauth2", method: :post, data: { turbo: false } %>
上述した通り、Rails7においてlink_toでPOSTリクエストを飛ばすにはdata: { turbo_method: :post }を使うしかないのですが、これはdata: { turbo: false }とは共存できないようです(Turboを使わないようにするので当然ですが。試しにdata: { turbo_method: :post, turbo: false }としてみると、POSTではなくGETリクエストになってしまいました)。
そのため、link_toではダメでbutton_toを使うしかないという結論にいたりました。
以上でGoogleでログインのサンプルアプリは実装完了ということにします。
間違い等のご指摘お待ちしております。
Discussion
Userモデルにfind_or_create_from_auth_hashメソッド追加 の箇所について質問させてください。
User モデルに Google 認証に関する処理が入っています。
ここはどのような意図で実装していらっしゃいますか?
Google 認証に関する情報を User モデルが知っているべきかという点が少し気になりました。
User 作成と Google 認証の関係性によっては、これを User の責務と捉えることもできるかもしれません。ですが個人的には User の責務の範囲外かなとも思っています。
理由は次の通りです。
上記実装では、Google から渡ってきたこの情報を User モデルに渡しています。
これは Google から渡ってくる情報の詳細を User が知っていることに意味します。
しかしこの内容は今後変わる可能性もあります。
そのときに User モデルの実装に変更が発生することには違和感があります。
User モデルそのもののふるまいが変わっているわけではないからです。
また、Google 以外の認証方法が求められた場合も同様でしょう。
なのでこの設計にした意図を知りたいです。
また私に考慮しきれていない点、違う観点があればぜひ教えていただきたいです 🙏
よろしくお願いします!
コメントありがとうございます!ご質問の件ですが、「Google認証部分を試すための記事を簡単に書くため」以上の意図はありません。
全くご指摘の通り、Userモデルから分けた方が設計上正しいと思います。これから長く育てていくアプリケーションを作る場合やチームで開発している場合は、間違いなくGoogleの認証情報はUserモデルから切り離すと思います。
一方でこの記事のような書き捨てコードの場合や、趣味の個人開発でGoogle認証だけできればいいようなアプリケーションを手早く作る場合は、Userモデルに直接書いてしまうかもと思いました。
重要なご指摘ありがとうございました。この記事を目にする他の方の参考になると思います。
お返事くださりありがとうございます!
そうですよね、Google 認証に焦点を当てている記事なのでその辺りは本質ではないですもんね。
むしろそこを事細かに書いてしまうと、本来 betacon さんがこの記事で伝えたかったことがぶれてしまうこともとても理解できます。
あと今回このような質問をした背景もお伝えしておけばよかったですね。
私がいろいろコードレビューをしている中で、betacon さんのこの記事を参考にされている方が多くいることを知りました。みなさんこの記事がとても助けになっているようです。そのことについても改めてお礼を申し上げます。
一方で、簡潔に表現されたこのコードをそのまま利用している方もいらっしゃいます。本来はこのような有益な記事を参考にしつつ、自分で設計を考えるのが望ましい姿だと思っています。ですからそのまま利用してしまっているというのは読み手側の問題なんですよね。
ので、純粋に becotan さんのお考えを聞いてみたかったのと、この質問が誰かの考えるきっかけになったらいいなという気持ちでした!
記事もすごく丁寧に書かれていますが、このお返事でも becotan さんがとても丁寧に物事を考えられていらっしゃるのが伝わってきました。
素敵なお返事をいただけて嬉しかったです。本当にありがとうございました!