この記事はプログラミング学習者がアプリ開発中に躓いた内容を備忘録として記事におこしたものです。内容に不備などあればご指摘頂けると助かります。
0.前提条件
X(旧Twitter)のクローンサイトの制作過程でGoogle認証を使ったログイン機能を実装している際に実装した内容をご紹介したいと思います。
フロントエンド:React(JavaScript) 19.1.0
バックエンド:Ruby on Rails(Ruby) 7.0.0 APIモード
インフラ:Docker
PC;Mac Book Air M2チップ
1.記事作成の経緯
これまでDeviseやdevise_token_authといったgemを使って認証機能を実装してきたのですが、SNS認証を使ったログインは実装経験が無かったもののアプリを利用する側としては何度も利用してきていたので挑戦することにしました。
せっかく実装できたので、備忘録を兼ねて記事として残しておきたいと思います。
2.実装概要
- Google Cloud側の設定としてクライアントIDとクライアントシークレットを生成する
- 必要なgemをインストールする
- ルーティング、コントローラー、omniauth.rbの設定
- フロントエンド側でボタンクリックした時のイベントハンドラでバックエンド側へ接続
3.認証情報の生成
1, 先ずはGoogle CloudでクライアントIDとクライアントシークレットを生成していきます。
-
Google Cloud Consoleにアクセスして新しいプロジェクトを作成していきます。
- 名前を決めてプロジェクトを作成します。
次に認証情報を取得していきます。 - 左上のナビゲーションメニューをクリックして、APIとサービス → 認証情報をクリック
- 上部バーにある認証情報を作成をクリックして、OAuthクライアントIDを選択
- 先に同意画面を作成するように怒られますので、右下の同意画面を構成をクリック
- 遷移先であるブランディングの画面で開始ボタンをクリック
- 必要項目を埋めていきます。アプリ名、ユーザーサポートメールを入力して次へ
- 対象を外部で選択して次へ
- 連絡先情報にメールアドレスを入力して次へ
- Google APIサービス:ユーザーデータに関するポリシーに同意するためのチェックボックスをクリックして続行
- 最後に作成ボタンを押してブランディングが作成されます。
- 作成が終わるとブランディングの画面に遷移します。
- サイドメニューのデータアクセスをクリック
- スコープを追加または削除をクリックして、出てきたウィンドウで「Google アカウントのメインのメールアドレスの参照」メニューのチェックボックスをクリック
- 上のクリックで選択した内容が非機密のスコープに反映されたことを確認して、画面したのSaveをクリック
- 次にサイドメニューのクライアントをクリックして上部バーのクライアントを作成をクリック
- アプリケーションの種類、名前、承認済みのリダイレクト URIを入力して画面下の作成ボタンをクリック。これでクライアントIDとクライアントシークレットが生成できました。
この画面を閉じてもしばらくはクライアントIDとクライアントシークレットを確認できるのですが、一定時間経つとクライアントシークレット側が確認できなくなるのでスクリーンショットなどで保管しましょう。
4.バックエンド側の実装
1, 必要なgemをインストールしていきます。
gem 'omniauth'
gem 'omniauth-google-oauth2'
gem 'omniauth-rails_csrf_protection'
上記のgemをインストールするためにコマンドを実行します。私はDocker上でのインストールでしたので、docker版も載せておきます。
docker compose exec サービス名 bundle install # コンテナ起動時
or
bundle install
手動でomniauth.rbを作成します。
Google Cloudで生成したクライアントIDとクライアントシークレットを参照する設定です。
また、最後の行ではgetとpostのリクエストのみを許可しています。
require 'omniauth'
OmniAuth.config.silence_get_warning = true # getリクエストの時の警告を無くす
# credentials.yml.encの内容を参照して承認する
Rails.application.config.middleware.use OmniAuth::Builder do
provider :google_oauth2,
Rails.application.credentials.google[:client_id],
Rails.application.credentials.google[:client_secret]
end
OmniAuth.config.allowed_request_methods = %i[get post]
2, Credentialsの設定
Google Cloudで生成したクライアントIDとクライアントシークレットを参照できるようにcredentialsに記入していきます。
docker compose exec -e EDITOR="vim" サービス名 bin/rails credentilas:edit # コンテナ起動時
or
EDITOR=vim bin/rails credentials:edit -e development
Dockerで作業されている場合、Docker内にvimがインストールされていない可能性があります。
インストールされていない状態で上記のコマンドを実行してもFile encryped and saved.
と出力されるだけでcredentialsを編集できませんのでご注意ください。
その場合はdocker compose exec サービス名 apt-get install vim
を実行してvimをインストールしてから改めてトライしてください。
credentialsを開くことができたら下記の内容を追記してください。
google:
client_id: 取得したクライアントID
client_secret: 取得したクライアントシークレット
作成したcredentials.yml.encは暗号化され、config/master.key、若しくは環境変数RAILS_MASTER_KEYを使用して復号化されます。
最終的に上で設定した認証情報をもとにGoogle認証を行うのですが、
私の場合、クライアントIDがcredentialsより正しく取得されずにGoogle認証が行われない不具合が発生しました。
その時の原因は下記のように開発環境用のcredentialsファイルと共用のcredentialsファイルの2つが生成されてしまったことによる認証情報の競合が起きていたことでした。
config/credentials/development.yml.enc
config/credentials.yml.enc
今回はテストや本番環境では使用せず開発環境だけでの動作を目的としていたため、development.yml.encを削除することで正しく動作させることができるようになりました。
credentialsを設定して認証機能の実装が終わったのでGithubにPushしたところ、NoMethodError: undefined method `[]' for nil:NilClassというエラーが発生してGithub Actionsのテストで止まってしまいました。
これは設定したcredentialsの中身をmaster.keyが無いために復号できずにnilと認識されていることが原因でした。
provider :google_oauth2,
Rails.application.credentials.google[:client_id],
Rails.application.credentials.google[:client_secret]
この問題に対して、master.keyを使って復号できるようにGithubのsecretsに登録。
また、Github Actions内で参照できるようにworkflowsのエラーが起きているテストのymlファイルにRAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}記載することでmaster.keyを参照できるように設定しました。エラーは解消されテストも問題なく通過できるようになりました。
3, ルーティングの設定
RailsはAPIモードなので、namespaceを使った階層構造で実装しています。
namespace :api do
namespace :v1 do
mount_devise_token_auth_for 'User', at: 'users', controllers: {
omniauth_callbacks: 'api/v1/omniauth_callbacks'
}
下のrails routesのリストに載っている青枠のhttp://localhost:3000/api/v1/users/google_oauth2
にアクセスがあるとGoogle認証が走り、認証が完了したらリダイレクト先としてGoogle Cloudで設定した赤枠のhttp://localhost:3000/omniauth/google_oauth2/callback
に遷移します。
Googleの認証画面への遷移はOmaniauthミドルウェアが自動で行います。
認証が終わってリダイレクトして、http://localhost:3000/omniauth/google_oauth2/callback
にアクセスがあるとapi/v1/omniauth_callbacks
コントローラー内のredirect_callbacksメソッドを動かします。
4, コントローラーの実装
Google側で認証された情報がrequest.env['omniauth.auth']で取得できますので、そこから必要な情報を取得していきます。
create_new_auth_tokenはdevise_token_authというGemのメソッドで必要なトークン情報を全て生成してくれるので便利です。
ユーザー情報の保存できたら、ルート画面(http://localhost:5173
)へ遷移するように設定しており、そのURLに生成したトークン情報を埋め込んでいます。フロントエンド側で埋め込まれた情報をパラメーターとして受け取り使用します。
module Api
module V1
class OmniauthCallbacksController < ApplicationController
def redirect_callbacks
auth = request.env['omniauth.auth'] # googleから認証情報を取得
return if auth.nil?
# request.env['omniauth.auth']にリクエストパラメータのユーザー情報が入ってくる
user = User.from_omniauth(request.env['omniauth.auth'])
if user.persisted?
# create_new_auth_tokenでトークン情報を全て生成する
token = user.create_new_auth_token
if user.save
# フロントエンド側でトークン情報を取得できるようにURLのパラメーター内にトークン情報を入れる
redirect_to "http://localhost:5173?status=success&access-token=#{token['access-token']}&uid=#{user.uid}&client=#{token['client']}&expiry=#{token['expiry']}"
end
else
render json: { status: 'ERROR', message: '401 Unauthorized', data: user.errors }, status: :unprocessable_entity
end
end
end
end
end
下のモデルではコントローラー内で使っているメソッドを記述しています。
Googole側から取得した認証情報でサーバー内に該当のユーザーが居るかどうか確認して、居なければ認証情報をもとに新しくユーザーデータを作成します。
データベースのバリデーションでパスワード、電話番号、誕生日が必須なので、便宜上ダミーデータを使用しています。パスワードに関してはよりセキュアな設定も可能なのですが、今回は開発環境だけなので直ぐに分かる内容で設定しています(てへぺろ)。
def self.from_omniauth(auth)
find_or_create_by(provider: auth.provider, uid: auth.uid) do |user|
user.email = auth.info.email
user.password = 'password'
user.name = auth.info.name
user.phone_number = '090-1234-5678'
user.birthday = '20250101'
end
end
5.フロントエンド側の実装
続いてバックエンド側から送られてきた認証情報を取得していく処理をフロントエンド側で実装します。
import React, { useEffect } from "react";
import styled from "styled-components";
import GoogleIconImage from "../../assets/google_icon.png";
import { useNavigate, useSearchParams } from "react-router-dom";
const Button = styled.button`
background-color: white;
border-radius: 20px;
width: 300px;
height: 40px;
text-align: center;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
&: hover {
background-color: #e1e3e1;
}
`;
export function GoogleIcon() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const handleGoogleLogin = () => {
// googleの認証画面へ遷移して認証を行う
window.location.href = `${
import.meta.env.VITE_API_URL
}/api/v1/users/google_oauth2`;
};
// useEffectでsearchParamsが変わった時に処理を始動させる
useEffect(() => {
// 取得するparamsの中にaccess-tokenが含まれている場合に始動
if (searchParams.has("access-token")) {
// URLのパラメーターからトークン情報を取得
const access_token = searchParams.get("access-token");
const client = searchParams.get("client");
const uid = searchParams.get("uid");
// ローカルストレージにトークン情報を保管
localStorage.setItem("access-token", access_token);
localStorage.setItem("client", client);
localStorage.setItem("uid", uid);
// ログイン後のメインページへ遷移
navigate("/main");
}
}, [searchParams]);
return (
<Button onClick={handleGoogleLogin}>
<img src={GoogleIconImage} alt="google_ico" width="24px" height="24px" />
Google で登録
</Button>
);
}
VITE_API_URL="http://localhost:3000"
onClickイベントで設定したhandleGoogleLoginを皮切りにGoogle認証が動き出します。
window.location.hrefで指定のURLへ遷移させます。import.meta.env.VITE_API_URLは事前に.envで設定したURLを指しています(VITEを使用した場合の書き方)。
その後は先述のバックエンド側での処理が行われ、パラメーターにトークン情報が入った状態で返ってきます。
useSearchParamsフックを使ってパラメーター(searchParams)に変化があった場合に察知してuseEffect内のコードを実行します。パラメーターにトークン情報が入っていることを確認して取得した情報をローカルストレージに保管します。
最後にログアウトの実装です。
import React from "react";
import styled from "styled-components";
import axios from "axios";
import { useNavigate } from "react-router-dom";
import toast from "react-hot-toast";
import { HandleError } from "../../utils/HandleError";
const Button = styled.button`
background-color: black;
border-radius: 20px;
text-align: center;
display: flex;
justify-content: center;
align-items: center;
width: 200px;
height: 40px;
color: white;
border: none;
font-size: 20px;
margin-bottom: 20px;
&:hover {
background-color: #1b1b1b;
cursor: pointer;
}
`;
const Span = styled.span``;
export const SignOutIcon = () => {
const access_token = localStorage.getItem("access-token");
const client = localStorage.getItem("client");
const uid = localStorage.getItem("uid");
const navigate = useNavigate();
const handleSignOut = async () => {
try {
const response = await axios.delete("/users/sign_out", {
headers: {
"access-token": access_token,
client: client,
uid: uid,
},
});
console.log(response.data);
if (response.status === 200) {
console.log("Signed out successfully!");
localStorage.clear();
navigate("/");
}
} catch (error) {
HandleError(error);
}
};
return (
<Button onClick={handleSignOut}>
<Span>サインアウト</Span>
</Button>
);
};
api/v1/users/sign_outへアクセスすることでdevise_token_authのログアウトするためのメソッドが動きます。ログアウト完了後はローカルストレージに保存されているトークン情報を削除することで完全にログアウトしたことになります。
以上で実装内容の説明を終わります。
最後までご覧頂きありがとうございました。
6.参考資料
Rails 7 × Googleログイン – omniauth-google-oauth2で簡単OAuth認証
useSearchParams
Model Concerns