表現の改善にAIを活用しています。
あらかじめご了承ください。
はじめに
バックエンドでは、JWT と refresh token を用いてユーザーセッションを管理しています。
一方、フロントエンドでも、ユーザーの認証状態や権限情報を保持し、画面表示を制御するための仕組みが必要です。
本記事では、このプロジェクトにおけるフロントエンド側のセッション管理の実装について紹介します。
なぜフロントエンド側のセッション管理が必要か
本システムには、サインイン状態やユーザー権限に応じて利用可能な画面が複数存在します。たとえば、以下のようなものです。
- 投票実行画面:サインイン中の一般ユーザーが利用可能
- 新規投票作成画面:サインイン中の管理者が利用可能
これらの画面では、現在のユーザーが認証済みであるか、またどの権限を持っているかをフロントエンド側で即座に判定する必要があります。
画面遷移やページ更新のたびに認証 api を呼び出して状態を確認する設計にすると、レスポンス待ちによる表示遅延が発生し、ユーザー体験の低下につながります。
そこで本プロジェクトでは、認証状態や権限情報をフロントエンドでも管理し、必要な画面制御を高速に行えるようにしています。
セッション管理
ユーザーセッションを各コンポーネントで個別に管理すると、ログイン状態の共有や同期が難しくなります。
そのため、アプリケーション全体で共通のユーザーセッション情報を扱える仕組みが必要です。
このプロジェクトでは、React の Context と Custom Hook を利用してセッション管理を実装しています。
AuthProvider
const AuthContext = createContext(null);
export function AuthProvider({ children }) { ... }
React の createContext を利用すると、コンポーネント階層をまたいでデータを共有できます。
AuthProvider はフロントエンドにおけるセッション管理の中核となるコンポーネントです。ユーザー情報の保持や取得、サインアウト処理などを一元管理しています。
以降では、AuthProvider の主要な処理について説明します。
ステータス
// ユーザ情報(idやroleだけではなくprofile情報も含まれている)
const [user, setUser] = useState(null);
// 各処理のロード状態
const [isLoading, setIsLoading] = useState({
initializing: true,
fetchUser: false
});
user にはログイン中のユーザー情報を保持します。
また、isLoading では初期化処理やユーザー情報取得処理の実行状態を管理しています。
ユーザー情報の取得
const fetchUser = useCallback(async () => {
try {
...
// 認証済みユーザー取得 API を呼び出す
const response = await fetch(...);
if (response.success) {
const fetchedUser = response.user;
setUser(fetchedUser);
} catch (err) {
setUser(null);
throw err;
} finally {
...
}
}, [...]);
fetchUser は useCallback でメモ化しています。
これにより、依存関係が変化されない限り同じ関数インスタンスを再利用できます。
特に、この関数は後述する Context の公開値 (value) に含まれるため、不要な再生成を防ぐことで Context 利用コンポーネントの再レンダリングを抑制できます。
AuthProvider 内の他の関数も、同様の理由で useCallback によってメモ化しています。
アプリ起動時や認証状態を再確認したい場合は、fetchUser を実行して認証済みユーザー情報を取得します。
useEffect(() => {
fetchUser()
.catch(() => {}) // ← 401(未ログイン)は正常なので必ず握りつぶす
.finally(() => {
setIsLoading(prev => ({ ...prev, initializing: false }));
});
}, []);
AuthProvider のマウント時には fetchUser を実行します。
この処理を行わない場合、ブラウザのリロードやタブの再読み込み時に React の状態が初期化され、ログイン状態を復元できなくなってしまいます。
サインアウト
const signout = useCallback(async () => {
try {
// サインアウト api を呼び出す
await fetch(...);
} finally {
// accessTokenを削除
clearAccessToken();
// ユーザ情報をクリア
setUser(null);
}
}, []);
サインアウト時は api を呼び出したあと、保持している access token とユーザー情報を削除します。
これにより、フロントエンド側の認証状態を確実に破棄できます。
ステータスと関数の公開
const value = useMemo(() => ({
user,
isLoading,
fetchUser,
signout
}), [user, isLoading, fetchUser, signout]);
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
value は useMemo を用いてメモ化しています。
Context Provider に渡すオブジェクトを毎回新しく生成すると、値の内容が変わっていなくても Context を利用しているコンポーネントが再レンダリングされてしまいます。
そのため、依存値が変化したときだけ新しいオブジェクトを生成するようにしています。
AuthContext.Provider を通じて AuthProvider 内で管理している状態や関数を子コンポーネントへ公開しています。
これにより、アプリケーション内のどのコンポーネントからでも認証情報へアクセスできるようになります。
useAuth
各コンポーネントでは useAuth Hook を利用して AuthProvider の機能へアクセスします。
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
useAuth を利用することで、user や signout などの認証関連機能へ簡潔にアクセスできます。
また、AuthProvider の外側で誤って使用した場合は例外を発生させることで、実装ミスを早い段階で検知できるようにしています。
アクセス制御
アプリケーションには、サインイン済みユーザーのみアクセス可能な画面や、管理者のみアクセス可能な画面があります。
このプロジェクトでは、認証状態やロール(権限)に応じたアクセス制御を ProtectedRoute コンポーネントで実装しています。
export function ProtectedRoute({ children, requiredRoles, redirectToMap = {} }) {
const { user, isLoading } = useAuth();
const location = useLocation();
if (isLoading.initializing) {
return <div>Loading...</div>;
}
if (!user) {
return <Navigate to={ROUTES.ENTRANCE} state={{ from: location }} replace />;
}
if (!requiredRoles.includes(user.role)) {
const redirectTo = user.role in redirectToMap ? redirectToMap[user.role] : ROUTES.ENTRANCE;
return <Navigate to={redirectTo} replace />;
}
return children;
}
ProtectedRoute では、useAuth Hook を利用して現在のユーザー情報を取得します。
アクセス判定の流れは次のとおりです。
- 認証状態の初期化中はローディング画面を表示する
- 未サインインの場合は入口画面へリダイレクトする
- サインイン済みでも必要な権限を持っていない場合は、指定された画面へリダイレクトする
- 権限を満たしている場合のみ画面を表示する
これにより、各ページで個別に認証チェックを実装する必要がなくなり、アクセス制御を一元管理できます。
App.jsx の構成
App.jsx は React アプリケーションのエントリーポイントです。
ここでルーティングとアクセス制御の設定を定義しています。
export default function App() {
return (
<AuthProvider>
<div>
<Routes>
{/* 誰でもアクセス可能な画面 */}
<Route path={ROUTES.ROOT} element={<Navigate to={ROUTES.ENTRANCE} />} />
{/* 一般ユーザーのみアクセス可能 */}
<Route path={ROUTES.USER_HOME} element={
<ProtectedRoute
requiredRoles={[ROLES.USER]}
redirectToMap={{[ROLES.ADMIN]: ROUTES.ADMIN_HOME}}
>
<HomePage />
</ProtectedRoute>
} />
{/* 管理者のみアクセス可能 */}
<Route path={ROUTES.ADMIN_HOME} element={
<ProtectedRoute
requiredRoles={[ROLES.ADMIN]}
redirectToMap={{ [ROLES.USER]: ROUTES.USER_HOME }}
>
<AdminHomePage />
</ProtectedRoute>
} />
...
</Routes>
</div>
</AuthProvider>
);
}
AuthProvider でアプリケーション全体をラップすることで、すべての画面から認証情報へアクセスできるようになります。
また、権限が必要な画面を ProtectedRoute で囲むことで、ルーティング定義とアクセス制御を同じ場所で管理できます。
例えば、一般ユーザー向け画面に管理者がアクセスした場合は管理者向けホーム画面へ、管理者向け画面に一般ユーザーがアクセスした場合は一般ユーザー向けホーム画面へリダイレクトされます。
このように、各ロールに応じた遷移先を redirectToMap で柔軟に設定できるようにしています。
access token の管理
refresh token は有効期限が長く、漏洩時の影響も大きいため、JavaScript から参照できない HttpOnly Cookie に保存しています。
一方、access token は API 呼び出し時に頻繁に利用するため、フロントエンドのメモリ上で管理しています。
let accessToken = null;
export function setAccessToken(token) {
accessToken = token;
}
export function clearAccessToken() {
accessToken = null;
}
access token は localStorage や sessionStorage には保存せず、メモリ上にのみ保持しています。
これにより、ブラウザへ永続化されたトークンが残らず、漏洩リスクの軽減が期待できます。
ただし、メモリ上のデータはページのリロードやブラウザの再起動で失われます。
そのため、access token が存在しない場合は、refresh token を利用して access token を再発行する必要があります。
access token の再発行
async function refreshAccessToken() {
// access token 再発行 api を呼び出す
const response = await fetch(...);
const data = await response.json();
return data.accessToken;
}
access token 再発行 api を呼び出し、新しい access token を取得します。
この api は HttpOnly Cookie に保存された refresh token を利用して認証を行います。
認証必須 api へのアクセス
export async function apiFetch({
url,
auth = false
...
}) {
const headers = { ... };
const options = { ... };
if (auth) {
// access token を Authorization ヘッダーへ設定
headers["Authorization"] = `Bearer ${accessToken}`;
// refresh token 用 Cookie を送信
options.credentials = "include";
}
const response = await fetch(url, { ...options, headers });
if (!response.ok) {
// Access Token の期限切れ
if (...) {
accessToken = refreshAccessToken();
// 再発行後に同じリクエストを再送
...
}
}
}
認証が必要な api を呼び出す場合は、Authorization ヘッダーに access token を設定します。
access token の有効期限が切れていた場合は、refresh token を利用して新しい access token を取得し、同じリクエストを再実行します。
この仕組みにより、ユーザーは再ログインを意識することなく継続してアプリケーションを利用できます。
最後に
今回は、フロントエンド側のセッション管理の実装について紹介しました。
著者は React 初学者のため、今回の実装にも改善の余地や考慮不足な点があるかもしれません。お気づきの点や、より良い実装方法がありましたら、ぜひコメントでご指摘いただけると嬉しいです。
最後までお読みいただき、ありがとうございました。