はじめに
こんにちは!MaTTaと申します。プログラミングスクールRunteq50期生です。先日、生成AIを用いた習慣化支援RPGアプリ「3日目に魔王がいる」をMVPリリースしました。その技術要素を細かく切り出して順に備忘録として残していこうと思います。
今回はOmniAuthを用いたGoogle認証(ログイン)機能についてです。SNS認証ではdevise(とdevise token auth)を使ったパターンを聞くことが多いですが、自分のアプリ要件だと過剰な印象もあったので今回はそれを使わない方法で進めました。
参考
アプリ紹介記事
サービスURL
Githubリポジトリ
本編ここから
作業環境
- MacBook Air 2020 (Apple M1)
- macOS Sonoma 14.4.1
実装の流れ
このアプリでは、バックエンドにRails(APIモード)、フロントエンドにReactを採用しており、それぞれ個別にアプリをデプロイする形をとっています。
この構成でGoogleログインを実現するために、OmniauthでGoogleのOAuth認証を行い、JWT(JSON Web Token)を使用してセキュアにユーザー認証情報を管理します。ユーザーがGoogleでログインすると、Railsバックエンドでユーザー情報を取得し、JWTを生成してフロントエンドのReactに返します。ReactはこのJWTを使用して認証を維持し、APIリクエストを行う際にJWTをヘッダーに含めることで、ユーザー認証をシームレスに行います。
GoogleのAPIキーを取得
Google認証を行うために開発者のアカウントでGCP(Google Cloud Platform)のAPIキーが必要になります。
GCPサイトから「コンソール」を選択します
ヘッダーから、新しいプロジェクトを作成します。プロジェクト名は任意で構いません。
OAuth同意画面をメニューを選び、External(外部)を選択して作成ボタンを押します
次の画面で、アプリ名は任意、メールアドレス、デベロッパーの連絡先を任意に設定します。アプリのドメインという大項目は空欄で構いません。
アプリ名に"google"が入っているとエラーになるそうです
スコープの項では下記の3つのみを選択して更新ボタンを押し、保存して次へ進みます
- .../auth/userinfo.email
- .../auth/userinfo.profile
- openid
テストユーザーの項ではADD USERSに自身のメールアドレスを入力して保存します。
左ペインから認証情報を選択し、認証情報を作成します。その際「OAuthクライアントID」を選択します。
認証情報は下記の通り選択・入力して作成ボタンを押します。
- 名前:「ウェブクライアント」を選択
- 承認済みの JavaScript 生成元: http://localhost:3000 (バックエンドのURL。ひとまず開発用にローカル)
- 承認済みのリダイレクト URI: http://localhost:3000/auth/google_oauth2/callback
ここまで進めるとクライアントIDとクライアントシークレットが作成されます。後に使用するのでどこかにコピペしておきます。
他の人がアクセスできる場所には書かないようにしてください
バックエンド(Rails)側の設定
まず、ここで関係してくるGemをインストールします。
Gem (Github) | Description |
---|---|
rack-cors | ブラウザの同一生成元ポリシーを超えたリソース共有を可能にするミドルウェア。APIが異なるオリジンのWebページから安全にアクセスされることを可能にします。 |
dotenv-rails | 環境変数を .env ファイルからアプリケーションにロードするためのGem。開発とテストの環境設定を簡素化します。 |
omniauth | 複数のプロバイダー(Google、Facebookなど)を通じてOAuth認証をサポートする柔軟な認証システムを提供します。 |
omniauth-rails_csrf_protection | RailsアプリケーションでOmniAuthを使用する際に、CSRF攻撃から保護するためのGem。OmniAuthリクエストにCSRFトークン検証を追加します。 |
omniauth-google-oauth2 | GoogleのOAuth 2.0サービスを利用して認証を行うためのOmniAuth戦略。Googleアカウントを使ってユーザーを認証できます。 |
jwt | JSON Web Tokens(JWT)をエンコードおよびデコードするためのRubyライブラリ。トークンベースの認証や情報交換に使用します。 |
gem "rack-cors"
gem 'dotenv-rails'
gem 'omniauth'
gem 'omniauth-rails_csrf_protection'
gem 'omniauth-google-oauth2'
gem 'jwt'
# dockerの場合バックエンドdockerコンテナ内で実行
bundle install
事前セッティング
再ビルドや再起動しないと効果が反映されない面倒なファイルがいくつかあるので先に済ませます。
- app/config/initializers/omniauth.rb(ない場合は作成)
RailsアプリケーションのミドルウェアスタックにOmniAuthのビルダーを追加します。
Rails.application.config.middleware.use OmniAuth::Builder do
provider :google_oauth2, ENV['GOOGLE_CLIENT_ID'], ENV['GOOGLE_CLIENT_SECRET']
OmniAuth.config.allowed_request_methods = [:post, :get]
end
- config/application.rb
ミドルウェアスタックにセッションミドルウェアを追加。コード中盤あたり、どこでも構わないので差し込みます。
config.middleware.use ActionDispatch::Cookies
config.middleware.use ActionDispatch::Session::CookieStore, key: '任意のキー'
- backend/.env.local
環境変数にGoogleのAPIキーを記述しておきます。JWT_SECRET_KEYについては後述しますが合言葉に相当するものなので、任意の、予測されない長めのワードを記述しておきます。REACT_APP_API_URLはフロント側のURLです。URLを環境変数化しておくと、本番でURLが変わる時も各コードを書き換えることなく対応できます。
GOOGLE_CLIENT_ID=xxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=xxxxxxxx-xxxxxxxxxxxx-xxxxxxxxxxxxxx
JWT_SECRET_KEY=app_omniauth_jwt_key
REACT_APP_API_URL=http://localhost:8000
- app/config/initializers/cors.rb
CORS(Cross-Origin Resource Sharing)は、ウェブページが異なるドメイン間で安全にリソースをリクエストできるようにするための標準技術です。Railsはセキュリティの観点からデフォルトでは遮断するようになっているため、下記のように明示的に許可しておく必要があります。
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins "localhost:8000", "本番環境のフロントURLも指定可能"
resource "*",
headers: :any,
methods: [:get, :post, :put, :patch, :delete, :options, :head]
end
end
各設定を反映するため再ビルドします。
docker compose build
ここでのテーブル設定
ここではユーザー情報と認証情報を保存するモデル・テーブルを作成します。全てをUserモデルに統合しても構いませんが、ここではセキュリティ度の高いものは認証テーブルに切り分けました。Userモデルカラムなどはその時に作るアプリの設定に合わせてください。
# Userのカラムはアプリ要件に従って設定
rails g model User email:string name:string nickname:string profile:text
rails g model UserAuthentication user:references provider:string uid:string
application_controller.rb
Railsの各コントローラに認証機能を渡します。具体的にはAPIにアクセスするユーザーが適切に認証されていることを保証し、無許可のアクセスを防ぐ役割を果たしています。
ここで@current_user
を取得し、各コントローラで使えるようにしています。
class ApplicationController < ActionController::API
before_action :authenticate_request
protected
def authenticate_request
header = request.headers['Authorization']
header = header.split(' ').last if header
begin
@decoded = JwtService.decode(header)
if @decoded["provider"] == "guest"
@current_user = User.find(@decoded["user_id"])
else
user_auth = UserAuthentication.find_by(uid: @decoded["google_user_id"], provider: @decoded["provider"])
@current_user = user_auth.user if user_auth
end
Rails.logger.info(@current_user)
unless @current_user
raise ActiveRecord::RecordNotFound, 'User not found'
end
rescue ActiveRecord::RecordNotFound, JWT::DecodeError => e
Rails.logger.error "認証エラー: #{e.message}"
render json: { errors: e.message }, status: :unauthorized
end
end
end
詳細解説
-
before_action :authenticate_request
:- この行は、**
authenticate_request
**メソッドを、コントローラーのアクションが実行される前に毎回呼び出すよう設定しています。このメソッドはユーザー認証を行うためのもので、リクエストが有効な認証情報を持っているかをチェックします。
- この行は、**
-
protect_from_forgery with: :exception
:- Railsが提供する標準的なセキュリティ機能で、CSRF(クロスサイトリクエストフォージェリ)攻撃を防ぎます。この設定により、セッションを変更する可能性のあるリクエストは、正しいCSRFトークンを含む必要があります。トークンが不正または欠落している場合、例外が発生します。
-
skip_before_action :verify_authenticity_token
:- この行は、CSRFトークンの検証をスキップするよう指定しています。APIモードでの開発や、外部からのAPIリクエストを受け入れる場合によく使用されます。この設定は、**
protect_from_forgery
**の設定と矛盾しているため、使用する際は注意が必要です。
- この行は、CSRFトークンの検証をスキップするよう指定しています。APIモードでの開発や、外部からのAPIリクエストを受け入れる場合によく使用されます。この設定は、**
-
Authorizationヘッダーの取得:
- リクエストから**
Authorization
**ヘッダーを取得します。このヘッダーは通常、Bearerトークンとして送信されるJWTを含んでいます。
- リクエストから**
-
トークンの抽出とデコード:
- トークンをスペースで分割し、その最後の部分(通常はJWTトークン自体)を抽出します。
- **
JwtService.decode
**メソッドを使用して、トークンをデコードします。デコードされたトークンからユーザー識別情報を取得します。
-
ユーザー認証の実行:
- **
UserAuthentication
**モデルを使用して、デコードされたトークンの情報に基づき、該当するユーザー認証情報をデータベースから検索します。 - 認証情報が存在する場合、関連するユーザーオブジェクトを**
@current_user
**に設定します。
- **
-
認証の検証:
- **
@current_user
が存在しない場合(つまりユーザーが見つからなかった場合)、ActiveRecord::RecordNotFound
**例外を発生させます。
- **
-
エラーハンドリング:
- 例外が発生した場合(ユーザーが見つからない、またはJWTのデコードエラー)、エラーメッセージをログに記録し、クライアントに**
unauthorized
**ステータスとともにエラーメッセージをJSON形式で返します。
- 例外が発生した場合(ユーザーが見つからない、またはJWTのデコードエラー)、エラーメッセージをログに記録し、クライアントに**
sessions_controller.rb
Google認証を使用してユーザーの認証情報を処理し、セッションを作成する役割を担います。また、ユーザーが未登録であればここでUser、UserAuthenticationを更新することでユーザー登録も一気に完了させています。セッション作成前なのでskip_before_action :authenticate_request, only: [:create]
をしておく必要があります。
JWT(JSON Web Token)が出てきますがこれが重要。
JWTとは、セキュリティが強化された方式で情報をJSON形式で交換するためのスタンダードです。トークンは、エンコードされたJSONオブジェクトを含み、これを用いて様々な情報を安全に伝達することができるため、認証などの情報交換の媒体として用いられます。
JWTについて詳細
JWT (JSON Web Tokens) のメカニズム
-
Header(ヘッダー):
- トークンのタイプ(通常はJWT)と使用されるハッシュアルゴリズム(例えばHMAC SHA256やRSA)を宣言します。
-
Payload(ペイロード):
- トークンに含まれる主張(claims)を含みます。これはトークンの発行者、有効期限、ユーザーID、権限などの情報が含まれます。
-
Signature(署名):
- ヘッダーのエンコードされた値、ペイロードのエンコードされた値、秘密鍵を使用して生成された署名です。これにより、トークンが途中で改ざんされていないかを検証できます。
これらの部分はBase64でエンコードされ、ピリオド(.
)で区切られて一つの文字列として表現されます。トークンはクライアントとサーバー間でHTTPヘッダー、URLパラメータ、またはリクエストボディを通じて送信され、受信者は署名を検証してトークンの真正性とデータの完全性を確認します。
JWTの必要性と利点
-
セキュリティ:
- JWTは情報を暗号化し、トークンの署名を使用してその完全性を保証します。これにより、データが中間者によって改ざんされることなく安全に通信されます。
-
スケーラビリティ:
- JWTはセッション情報をクライアント側に保持するため、サーバー側でのセッション情報の管理が不要になります。これにより、サーバーのリソースを節約し、大規模なアプリケーションのスケーラビリティを向上させることができます。
-
柔軟性:
- トークンはWeb、モバイルアプリ、デスクトップアプリなど、様々な環境で簡単に使用することができます。また、クロスドメイン認証にも適しています。
-
デバッグとテストの容易性:
- JWTのデコードはシンプルであり、開発中に内容を簡単に確認できます。また、特定の期限や権限を模倣したトークンを生成してテストすることが容易です。
class SessionsController < ApplicationController
skip_before_action :authenticate_request, only: [:create]
def create
frontend_url = ENV['REACT_APP_API_URL']
user_info = request.env['omniauth.auth']
google_user_id = user_info['uid']
provider = user_info['provider']
token = generate_token_with_google_user_id(google_user_id, provider)
user_authentication = UserAuthentication.find_by(uid: google_user_id, provider: provider)
if user_authentication
Rails.logger.info("アプリユーザー登録されている")
redirect_to "#{frontend_url}/MyPage?token=#{token}", allow_other_host: true
else
Rails.logger.info("まだアプリユーザー登録されていない")
# ユーザーを作成(カラムはアプリの内容によって変更する)
user = User.create(nickname: "新規ユーザー", achievement: 0, current_avatar_url: "/default/default_player.png")
UserAuthentication.create(user_id: user.id, uid: google_user_id, provider: provider)
redirect_to "#{frontend_url}/MyPage?token=#{token}", allow_other_host: true
end
end
private
def generate_token_with_google_user_id(google_user_id, provider)
exp = Time.now.to_i + 24 * 3600
payload = { google_user_id: google_user_id, provider: provider, exp: exp }
hmac_secret = ENV['JWT_SECRET_KEY']
JWT.encode(payload, hmac_secret, 'HS256')
end
end
コードの詳細解説
-
スキップ認証リクエスト:
-
skip_before_action :authenticate_request, only: [:create]
この行は、**create
アクションが実行される前に、親クラスApplicationController
で設定されているauthenticate_request
**メソッドをスキップすることを指示しています。これにより、新しいセッションを作成する際にユーザーが未認証状態でもアクセスを許可します。
-
-
セッションの作成 (
create
メソッド):- ユーザーがGoogle認証を通じてログインすると、このメソッドが呼ばれます。OmniAuthは認証情報を環境変数**
omniauth.auth
**に格納します。
- ユーザーがGoogle認証を通じてログインすると、このメソッドが呼ばれます。OmniAuthは認証情報を環境変数**
-
認証情報の取得:
-
user_info = request.env['omniauth.auth']
は、OmniAuthが提供する認証情報を取得します。これにはユーザーIDやプロバイダー名などが含まれます。
-
-
トークンの生成:
- **
generate_token_with_google_user_id
**メソッドを使用して、JWT(JSON Web Token)を生成します。このトークンはユーザーIDとプロバイダー情報を含んでおり、有効期限も設定されています。
- **
-
ユーザー認証情報の検索:
- **
UserAuthentication.find_by(uid: google_user_id, provider: provider)
**は、データベースから該当するユーザー認証情報を検索します。
- **
-
リダイレクト処理:
- 認証情報が存在する場合(つまりユーザーが既に登録されている場合)、トークンをクエリパラメータとして含むURLにリダイレクトします。これによりフロントエンドでユーザーを認証し、適切なページにアクセスさせることができます。
- 認証情報が存在しない場合は、ユーザーを登録フォームページにリダイレクトし、新規登録を促します。
generate_token_with_google_user_id
メソッド
このプライベートメソッドは、GoogleユーザーIDとプロバイダー情報を使用してJWTを生成します。JWTは、exp
(期限)を含むペイロードと秘密鍵(**JWT_SECRET_KEY
**環境変数から取得)を使用してエンコードされます。これにより、生成されたトークンは24時間後に期限切れとなります。
users_controller.rb
usersコントローラーで、@currentuser
をフロントに返せるようにしておきます。
application_controller
を継承し、そのauthenticate_request
を使っています。
class Api::V1::UsersController < ApplicationController
skip_before_action :authenticate_request, only: [:index, :show]
# それぞれアプリ要件に従って設定
def index; end
def show; end
def update; end
def destroy; end
# カレントユーザーを返す
def current
if @current_user
render json: { user: @current_user }
else
render json: { error: '認証情報を取得できません' }, status: :unauthorized
end
end
end
ルーティング
関係しているのは下記二つ
- get '/auth/:provider/callback', to: 'sessions#create'
- get 'users/current', to: 'users#current'
Rails.application.routes.draw do
# google認証にアクセス
get '/auth/:provider/callback', to: 'sessions#create'
# ユーザー登録のルート(API)
namespace :api do
namespace :v1 do
# カレントユーザーの呼び出し
get 'users/current', to: 'users#current'
end
end
end
フロントエンド(React)側の設定
ユーザーが押すGoogle認証ボタンを設置し、それをトリガーにしてバックエンド側にアクセスしてGoogle認証を機能させます。また、認証状態に至ったあとはフロントアプリ全体でそのcurrent_user情報を共有します。
事前セッティング
バックエンド同様、環境変数ファイルは先に設定しておきます。
.env.local
(あるいは.env
を作成。バックエンドにも同名のファイルを作りましたが、フロントエンド側のディレクトリ配置します)
REACT_APP_API_URL=http://localhost:3000
認証情報を管理する機能
ページごとに認証を確認しては煩雑になるのでフロントエンドのディレクトリにsrc/providers/auth.jsx
を作成しアプリケーション全体でユーザーの管理するようにします。具体的には下記の機能を持ちます。
認証コンテキスト
AuthContextを作成し、useAuthフックを通じてこのコンテキストにアクセスできるようにしています。
認証プロバイダー
AuthProviderコンポーネントは、アプリケーション全体の認証状態を管理します。このコンポーネントはの機能を提供します:
機能 | 説明 |
---|---|
トークンの管理 | URLのクエリパラメータまたはlocalStorageから認証トークンを取得し、状態に保存します。 |
現在のユーザー情報の取得 | トークンが存在する場合、APIから現在のユーザー情報を取得し、状態に保存します。 |
ログアウト機能 | ユーザー情報とトークンをクリアし、localStorageからトークンを削除します。 |
コンテキストの提供 | AuthContext.Providerを通じて、token、logout、setToken、currentUser、setCurrentUserを子コンポーネントに提供します。これにより、アプリケーション内の他のコンポーネントがこれらの情報や機能にアクセスできるようになります。 |
import React, { createContext, useContext, useEffect, useState } from "react";
import { API_URL } from "../config/settings";
const AuthContext = createContext();
export const useAuth = () => useContext(AuthContext);
export const AuthProvider = ({ children }) => {
const [token, setToken] = useState("");
const [currentUser, setCurrentUser] = useState(null);
useEffect(() => {
// URLからクエリパラメータを解析してトークンを取得
const query = new URLSearchParams(window.location.search);
const tokenFromUrl = query.get("token");
if (tokenFromUrl) {
setToken(tokenFromUrl);
localStorage.setItem("authToken", tokenFromUrl); // トークンをlocalStorageに保存
} else {
const storedToken = localStorage.getItem("authToken");
if (storedToken) {
setToken(storedToken);
}
}
}, []);
useEffect(() => {
if (token) {
fetch(`${API_URL}/api/v1/users/current`, {
headers: {
Authorization: `Bearer ${token}`,
},
})
.then((response) => {
if (!response.ok) {
throw new Error("Failed to fetch user");
}
return response.json();
})
.then((data) => setCurrentUser(data.user))
.catch((error) => {
console.error("Error fetching user:", error);
logout(); // エラー時にログアウト
});
}
}, [token]);
const logout = () => {
setCurrentUser(null); // ユーザー情報をクリア
setToken(""); // トークンをクリア
localStorage.removeItem("authToken"); // localStorageからトークンを削除
};
return (
<AuthContext.Provider
value={{ token, logout, setToken, currentUser, setCurrentUser }}
>
{children}
</AuthContext.Provider>
);
};
アプリケーション全体に認証コンテキストを提供する
frontend/src/App.js
で<AuthProvider></AuthProvider>
ですべてのコンポーネントを囲います。
今回の例ではHeaderやFooterも含めて丸ごと囲い込んでいます。
import { BrowserRouter } from "react-router-dom";
import { AppRoutes } from "./routes";
import { AuthProvider } from "./providers/auth";
import { Header } from "./components/Header";
import { Footer } from "./components/Footer";
import "./App.css";
const App = () => {
return (
<div>
<AuthProvider>
<BrowserRouter>
<Header />
<AppRoutes />
<Footer />
</BrowserRouter>
</AuthProvider>
</div>
);
};
export default App;
ここまでを設定すると、個別のコンポーネントにおいてcurrentUserを取り出したり、ログアウトすることができます。
import { useAuth } from "../providers/auth";
//他省略
export const TestPage = () => {
const { currentUser, token, logout, setCurrentUser } = useAuth();
console.log(currentUser.nickname); // 例:ログインユーザーのニックネーム
//例:ログアウト機能
const handleClickLogout = () => {
logout(); // トークンをクリアしてログアウト処理
console.log("logout");
navigate(RoutePath.Home.path); // ログインページにリダイレクト
};
//他省略
}
ログインページ
ユーザーがGoogleを通じて認証するためのUIと機能を提供します。具体的には、認証用のトークンを受け取った後の処理と、ユーザーがGoogle認証を開始するためのボタンが含まれています。
ここで重要なのはhandleGoogleAuth
の部分です。${API_URL}/auth/google_oauth2
にアクセスすることでバックエンド側のomniauthのgoogle 認証ルートを辿らせることができます。そのほかはアプリの要件に従って書き換えてください。
私の場合はアプリのトップページに作成しています。
import React, { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../providers/auth";
import { Link } from "react-router-dom";
import { RoutePath } from "../config/route_path";
import { API_URL } from "../config/settings";
export const HomePage = () => {
const navigate = useNavigate();
const { setToken, currentUser } = useAuth();
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const token = params.get("token");
if (token) {
setToken(token);
localStorage.setItem("auth", token);
}
}, [setToken, navigate]);
const handleGoogleAuth = (e) => {
e.preventDefault();
const form = document.createElement("form");
form.method = "GET";
form.action = `${API_URL}/auth/google_oauth2`;
document.body.appendChild(form);
form.submit();
};
return (
<div className="min-h-screen flex flex-col items-center justify-center">
<div className="space-y-4">
{/* ログイン済みの場合はマイページへのリンク、そうでない場合はログインボタン */}
{currentUser ? (
<Link to="/MyPage" className="btn btn-accent gap-2 w-full">
マイページへ
</Link>
) : (
<>
<button className="btn btn-accent gap-2 w-full" onClick={handleGoogleAuth}>
Googleログイン
</button>
<>
</div>
</div>
);
};
ここまででログイン機能が実装されます。
流れの整理
- ログインボタンを押すと、バックエンドの
/auth/google_oauth2
にアクセスします。 - そんなルーティングは存在しないように見えますが、実はOmniauthで
/auth/google_oauth2
というエンドポイントを提供するように事前に設定しています。これを用い、一旦Googleの認証ページにリダイレクトします。
Rails.application.config.middleware.use OmniAuth::Builder do
provider :google_oauth2, ENV['GOOGLE_CLIENT_ID'], ENV['GOOGLE_CLIENT_SECRET']
OmniAuth.config.allowed_request_methods = [:post, :get]
end
- Googleサイト側の認証ボタンを押すと、Google Cloud Platformで予め設定していた
承認済みのリダイレクト URI: http://localhost:3000/auth/google_oauth2/callback
にリダイレクトします。 - バックエンドのルーティング
get '/auth/:provider/callback', to: 'sessions#create'
を実行します。 -
sessions_controller
でUser、UserAuthenticateテーブルが更新され、トークンが生成してフロントのマイページにリダイレクトします。この際、トークン情報をフロントにパラメータとして渡します。
振り返り
そこそこのボリュームになってしまい素直にDeviseを使った方が良かったかなと思うこともありましたが、手作り部分が発生したことで全体の動きを学ぶ良い機会になったと思います。最後の「流れの整理」だけでも頭に叩き込んでおきたいと思います。
この記事を書いた人
友達募集中ですw