最近、Rails APIモード × SPAでWebアプリの実装を行いました。
その際に、ログイン状態を保持するのにとても苦労したので、私自身の振り返りも含めてまとめてみたいと思います。
この記事で得られる知識
1. deviseとdevise_token_authの違い
2. オリジン、クロスオリジン、CORS設定の知識
3. devise_token_authの認証情報保持の流れ
5. セッション認証とトークン認証
devise_token_authの実装方法を1~10まで説明するとかなり長くなるので、あくまで「認証情報の取得・保持方法とその流れ」といった範囲で解説します。
詳しくはこちら:https://qiita.com/kazama1209/items/caa387bb857194759dc5
devise_token_authとは?
1. deviseとは?
deviseとは、Warden gemを基にした認証(authentication)ライブラリです。
deviseを導入すると、コントローラーやビュー、ヘルパーなど、適切なユーザー認証ソリューションを構築するために必要なファイルを追加してくれます。
私自身、Sorceryの後にdeviseを使用し、ユーザー認証周りを自動で立ち上げてくれた時には非常に感動したのを覚えています。
また、deviseでは10個ほどのモジュールが付属しており、アプリケーションで認証をどのように扱いたいかを正確に指定できます。
参考:https://techracho.bpsinc.jp/hachi8833/2023_09_01/133452
https://github.com/heartcombo/devise
2. devise_token_authとは?
devise_token_authは、RailsのSPA等でトークンベースの認証を提供するライブラリです。
devise_token_authでは、「uid」「client」「access-token」の3つの情報を組み合わせて認証状態を管理しています。
クライアントはこれらの認証情報をサーバーへ送信し、トークンを検証して認証を行います。
また、デフォルトの設定ではaccess-tokenをリクエストごとに更新するため、クライアント側では動的にトークンを保持・更新し、シームレスに認証状態を維持する必要があります。
devise_token_authで使用可能な認証方式と最善のもの
私の場合、参考記事を元にCookieで認証保持を行いましたが、SPAではCookie以外にLocalStorageやIn-Memoryというものを使用しても認証情報の保持が可能なようです。
ちなみにコストの制限を外した場合の話ですが、「認証保持の安全性」という観点では「認証プラットフォーム(Auth0など)」というものが最善のようです。
どういったサービスなのでしょうか?Auth0から引用してきました。
Auth0から引用:
Auth0を活用すると、アプリケーションに高度な認証と認可機能を追加できます。
Auth0では複数のアイデンティティプロバイダーのユーザーを一元管理し、ブランド化されたシームレスなサインアップとログインエクスペリエンスを提供します。
高度なカスタマイズにより、アクセスをきめ細かく制御できるため、複雑なセキュリティ要件にも対応できます。
つまり、アイデンティティプロバイダーとはLINEログインやAppleログインのように、ユーザーの「本人確認(認証)」を外部で行ってくれるサービスであり、「ログイン処理を安全に代わりに行ってくれる外部サービス」のことです。
※たとえば、Twitter(X)でログインする際に「LINEでログイン」を選べる場合、LINEがアイデンティティプロバイダーとして動作しているといった具合。
また、「高度なカスタマイズが可能→複雑なセキュリティ要件を満たすことが出来る」ので、「認証保持の安全性」という観点で最善なのだと分かりました。
私の中では、「Auth0のようなサービスを利用する場合は料金が発生するが、複雑なセキュリティ要件にも対応できるため安全性が高い」という認識です。
参考:https://qiita.com/kazama1209/items/caa387bb857194759dc5
https://qiita.com/P-man_Brown/items/5071f10c3cda33c23d2e
https://auth0.com/docs/articles
3. deviseとdevise_token_authの違い
deviseのログイン機能ではセッション認証を使用しており、ユーザー情報はRailsサーバー側で保持されます。
そのため、認証処理はRailsサーバーで完結し、フロントエンドは主にCookieを通じてセッションIDを送受信します。
一方、devise_token_authはRails APIモード向けのgemで、トークン認証を行います。
ユーザー情報はRailsサーバーに保持されず、発行されたトークン自体に認証情報を含んでいるため、フロントとバックエンド間でステートレスに認証状態を管理できます。
セッション認証とトークン認証の違い
セッション認証
セッション認証とは、ログインしたときにRailsサーバー側でセッションIDを発行し、セッションのユーザー情報と紐づけて管理する方式です。
ブラウザはセッションIDをCookieとして保存し、リクエストごとにサーバーへ送ります。
サーバーは受け取ったセッションIDをもとにユーザー情報を確認し、アクセス許可を判断します。
簡単に言うと、「Railsサーバーがユーザー情報を持って管理する認証方式」です。
セッション認証
ログイン
↓
Railsサーバーのセッションにユーザー情報を保持
↓
ユーザー情報からSession IDを発行(Session IDはユーザー情報を参照する鍵)
↓
Session IDをCookieへ保存
↓
クライアントからRailsサーバーへリクエスト
↓
Session IDを元に、Railsサーバーのユーザー情報を参照できる
↓
※Railsサーバーがユーザー情報を保持している点が重要
└ステートフルな状態
トークン認証
トークン認証は、ログイン時にRailsサーバーがアクセストークンを発行し、ブラウザがそのトークンを保持する方式です。
フロントはトークンを送信し、サーバーはトークンの有効性を確認してアクセスを許可します。
簡単に言うと、「ユーザー情報をRailsサーバーに保持せず、リクエスト単位でユーザー認証を行う方式」です。
トークン認証
ログイン
↓
Railsサーバーがアクセストークンを発行(トークンにユーザーIDや権限情報などを含める)
↓
トークンをブラウザで保持(LocalStorageやCookie、In-Memoryなど)
↓
クライアントからRailsサーバーへリクエスト
↓
送られてきたトークンをRailsサーバーが検証
↓
トークンの情報をもとにアクセス許可を行う
↓
※サーバーはユーザー情報を保持せず、トークンだけで認証できる点が重要
└ステートレスな状態
参考:https://qiita.com/shuhei_m/items/aa5aa4a06a555a0c57b5
CORS設定とは?
1. オリジンとクロスオリジン
まずオリジンについて説明します。
オリジンとは、ブラウザが「このリソースは同じ場所から来たものか、それとも別の場所から来たものか」を判断するときの単位です。
また、具体的には 「プロトコル」「ドメイン」「ポート」 の組み合わせで決まります。
オリジンの例
https://google.com
プロトコル:https
ドメイン名:google.com
ポート:80
一方、クロスオリジンとはオリジンが異なるもののことです。
2. CORS設定とは?
CORSとは「Cross-Origin Resource Sharing」の略ですが、これは「クロスするオリジン間でのリソースの共有」を指します。
つまるところ、CORS設定とは「異なるオリジン間での通信を許可する場合の設定」です。
※CORS設定は「同一生成元ポリシー (Same-Origin Policy)」というポリシーによって設けられた制限を緩めるものです。
APIモードだと、フロントとバックエンドでオリジンが変わる為、アクセスを許可する為にRails側でCORS設定を行う必要があります。
今回の例だと以下のようになります。
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins 'http://localhost:5173'
resource "*",
headers: :any,
expose: ["access-token", "expiry", "token-type", "uid", "client"],
methods: [:get, :post, :put, :patch, :delete, :options, :head],
credentials: true
end
end
origins: 許可するオリジンの指定。
resource: リクエストできるリソースを指定。
headers: 許可するリクエストヘッダーを指定。
methods: 許可するHTTPメソッドを指定。
credentials: クッキーや認証情報を含むリクエストを許可するかどうか指定。
参考:https://zenn.dev/mukkun69n/articles/8a04939fd66a4a
https://zenn.dev/tech_discovery/articles/6bca2518c48262
devise_token_authのCookieを使用した認証情報保持の流れ
devise_token_auth では、認証のために3つのトークン情報を使います。
①uid
②client
③access-token
これらがセットで1つの認証状態を表し、エンドポイントへリクエストを送るとaccess-tokenが更新される仕組みになっています。
トークンを更新しない設定方法
config/initializers/devise_token_auth.rbにて、「トークンを更新するか」や「トークンの有効期限」を設定できるようです。
トークンの更新をオフ
change_headers_on_each_request = false
トークン期限を2週間に設定
token_lifespan = 2.weeks
参考:https://devise-token-auth.gitbook.io/devise-token-auth/config/initialization
1. 認証保持の全体像
コードの例
①Axiosの設定
import applyCaseMiddleware from "axios-case-converter"
import axios from "axios"
import Cookies from "js-cookie"
let client = axios.create({
baseURL: "http://localhost:3000/api/v1",
withCredentials: true //ブラウザがCookieの送受信を行うことを宣言
});
const options = {
ignoreHeaders: true //headersにはapplyCaseMiddlewareを適用しない
}
client = applyCaseMiddleware(client, options); //snake_case ⇔ camelCase
client.interceptors.request.use((config) => { //client.interceptors.requestでリクエスト送信前に割り込みたい処理を書く
//.useまでつけることで、リクエスト直前に実行したい関数を定義できる
const accessToken = Cookies.get("_access_token");
const clientId = Cookies.get("_client");
const uid = Cookies.get("_uid");
if (accessToken && clientId && uid) { //既存の認証情報がある場合はconfigに追加
config.headers["access-token"] = accessToken;
config.headers["client"] = clientId;
config.headers["uid"] = uid;
}
return config; //return configしないと、認証情報がconfigに適用されない
});
client.interceptors.response.use((response) => { //client.interceptors.responseの場合は受信直後に割り込みたい処理が書けます
const accessToken = response.headers["access-token"];// responseの認証情報を取得し、既存のものを更新
const clientId = response.headers["client"];
const uid = response.headers["uid"];
if (accessToken ?? clientId ?? uid) {
if (accessToken) Cookies.set("_access_token", accessToken);
if (clientId) Cookies.set("_client", clientId);
if (uid) Cookies.set("_uid", uid);
}
return response;//return responseで認証情報の更新を確定
});
export default client
②コンポーネント
import MainLayout from "@/Layouts/MainLayout";
import { useState, useEffect } from "react";
import { Button, Box, Card, Image, Spacer, Center } from "@chakra-ui/react";
import { useNavigate } from "react-router-dom";
import client from "@/lib/api/client"; // Axios
export default function Profile() {
const navigate = useNavigate()
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
const [name, setName] = useState<string>("")
const [email, setEmail] = useState<string>("")
useEffect(() => {
const fetchItems = async () => {
try {
const res = await client.get("auth/validate_token"); //データ取得&トークン更新
const resAvatar = await client.get("auth/avatar"); //データ取得&トークン更新
setAvatarUrl(resAvatar.data.avatarUrl); //profile画像をセット
setName(res.data.name); //ユーザー名をセット
setEmail(res.data.email); //emailをセット
} catch (error) {
console.log(error);
}
}
fetchItems();
}, []) //初回レンダリング時に実行
.
.
.
(保存したStateで仮想DOMを描画)
devise_token_auth × React(SPA) × axiosの流れは下記のようになります。
ログイン時:「ログイン完了→headersでRailsがトークンを返す→認証情報をブラウザに保存→ログイン完了時のコンポーネントを表示できる」というようになります。
ログイン時に認証情報を取得&保持する流れ
(トークン認証)
ユーザーがログイン実行
↓
フロント:ログインリクエスト(post)
↓
バックエンド:(ログイン処理はdevise_token_authが内部で認証処理を実行)
↓
バックエンド:uid、client、access-tokenを発行し、ステータス200でresponseを返す
↓
フロント:ステータス200を取得&トークンをブラウザで保持
↓
フロント:「ログイン済みユーザー専用画面」への遷移をRailsサーバーへリクエスト
↓
バックエンド:uid、client、access-tokenをRailsサーバーが検証
↓
バックエンド:(トークンの情報をもとにアクセス許可を行う)
ログイン後:「Reactがエンドポイントへリクエスト→ブラウザが保存しているCookieを取得→リクエストと共に送る→Railsがresponseを返す→responseを受け取る→ブラウザにCookie保存→responseを元に画面を描画」
ログイン後に認証情報を取得&保持する流れ
※今回のケース
(トークン認証)
フロント:エンドポイントにリクエスト
└リクエスト直前にconfigを追加(.interceptors.request.use((config)))
└既存のトークン(uid、client、access-token)を取得 → headerに追加
↓
バックエンド:(devise_token_authが内部で認証処理を実行)
↓
バックエンド:current_api_v1_userをセット
↓
バックエンド:エンドポイントに応じた処理がcontrollerで実行
↓
バックエンド:responseを返す
↓
フロント:responseを取得
└response取得直前にconfigを追加(.interceptors.response.use((config)))
└新たなトークン(uid、client、access-token)を取得 → Cookieに追加
↓
フロント:responseを元に画面を描画