【React Router v6】【Javascript】ログイン状態によるアクセス制御を行う

フロントは ` React.js （JavaScript採用） `　、バッグエンドは ` Springboot ` で SPA アプリを作成しており、ログイン状態の管理に悩みました。

理由は下記の通りです。
- 認証情報を保持し、アクセス制御を行う機能が React.js にはデフォルトで備わっていない（Vue.jsには備わっている）
- React Route v6 が新しく、情報が少ない
- 情報があったと思ったら　TypeScript の情報だった

そこで、自分なりに設計し、実装しました。
# 1. はじめに｜できること

- 未ログイン状態
    - ログインページへアクセスした時、ログインページへ遷移する
    - ログインページ以外へのアクセスした時、ログインページに遷移する
- ログイン状態
    - ログインページへアクセスした時、設定したページ（本記事では「メインページ」）に遷移する
    - ログインページ以外へアクセスした時、ログインページに遷移する

# 2. 対象読者
下記全てに当てはまる人
- SPA アーキテクチャを採用し、フロントのアクセス制御方法に悩んでいる
- React Route v6 を採用している
- JavaScript で実装している

# 3. 結論
早速、結論を書いていきます。
## 3-1. アクセス制御の手法
SessionStorage に認可情報を保持し（下図参照）、アクセス時に、認可情報を確認してから Routing する。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/675074/efbcdfa9-28e2-d8e7-802c-80f27e3c3596.png)

※ API 側へ渡す認証トークンは Cookies に格納します。（本記事の趣旨とはズレるため割愛します）

## 3-2. 実装方法
まず login.js にて、 SessionStorage に認証状態を保持します。認可情報を持たせることで、認証済み状態とします。
※ `react-hook-form` を用いたバリデーションを使用していますが、使用しなくてもアクセス制御可能です。

```login.js
import React from 'react';
import axios from 'axios';
import { useForm } from 'react-hook-form';
import { Link, useNavigate } from 'react-router-dom';


const Login = () => {

	const navigate = useNavigate();

	// バリデーションのフォームを定義
	const { register, handleSubmit, watch, formState: { errors } } = useForm();

	// ログインフォームの送信ボタン押下時に実行
	const onSubmit = (data) => {

		// ログインURIを設定（ホスト名は .env ファイルからインポート）
		const loginPath = process.env.REACT_APP_API_HOST + '/login';

		// パラメータ設定
		var params = new URLSearchParams();
		params.append('userName', data.userName);
		params.append('password', data.password);
		
		// ログイン API へ POST
		axios.post(loginPath , params)
			.then((response)=>{

				// 認可情報を SessionStorage に設定
				sessionStorage.setItem('AUTHORITY', response.headers.authority);

				// /main にリダイレクト
				navigate('/main')
			})
  }

	return(
		<>
		<form onSubmit={handleSubmit(onSubmit)}>
			<div className='input-area'>
				<p className='event-title'>アカウント名</p>
					<input type='text' {...register("userName", {required: true})} />
				<p>{errors.userName?.type === 'required' && "アカウント名を入力してください。"}</p>
			</div>
			<div className='input-area'>
				<p className='event-title'>パスワード</p>
					<input type='password' {...register("password", {required: true})} />
				<p>{errors.password?.type === 'required' && "パスワードを入力してください。"}</p>
			</div>
			<input type="submit" value='ログイン' />
		</form>

		<Link to='/signup'>新規会員登録はこちら</Link>
			
		</>
	)
}

export default Login;
```
app.js にてルーティング設定をします。
仕組みとしては、コンポーネントのレンダリング前に、認可情報をチェックし、その状態に応じて、コンポーネントをレンダリングしたり、リダイレクトしたりします。
詳細はメソッドの直上にコメントしましたので、是非ご覧ください。

```app.js
import React from 'react';
import { Redirect, BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import './App.scss';
import Signup from './components/Main';
import Login from './components/Login';

const App = () => {

  // 認証確認メソッド
  // → 認証されていない場合、ログインページにリダイレクト
  const RequireAuth = ( props ) => {
    
    const myAuthority = sessionStorage.getItem('AUTHORITY');

    // 権限が「GENERAL」の場合、渡されたコンポーネントをレンダリング
    if(myAuthority=="GENERAL"){
      return props.component;
    }

    // 権限がない場合、ログインページへリダイレクト
    document.location = "/login";

  }

  // 非認証確認メソッド
  const RequireNoAuth = ( props ) => {
    
    const myAuthority = sessionStorage.getItem('AUTHORITY');

    // 権限がない場合、渡されたこのポーネントをレンダリング
    // ※ ログインページとユーザ新規登録ページに適用
    if(myAuthority == null){

      return props.component;

    }

    // 権限が存在する場合、メディア一覧ページへリダイレクト
    document.location = "/main";

  }
  

  return (
    <Router>
      <div className='App'>
        <Routes>
          <Route path='/login' element={<RequireNoAuth component={<Login />} />} />
          <Route path='/main' element={<RequireAuth component={<Main />} />} />
        </Routes>
      </div>
    </Router>
  );
}
export default App;
```
ログアウトにて認可情報を削除する必要があるので、掲載いたします。

```logout.js
import { useEffect } from 'react';
import axios from 'axios';
import { useNavigate } from 'react-router-dom';

function Logout() {

  	const navigate = useNavigate();

	useEffect(() => {

  	const logoutPath = process.env.REACT_APP_API_HOST + '/logout';

	// ログアウト API へ POST
	axios.get(logoutPath)
		.then((response)=>{

			// Cookies の JWTTOKEN のバリューを削除
			document.cookie = "JWTTOKEN=; expires=0";

			// SessionStorage の認可情報を削除
			sessionStorage.removeItem('AUTHORITY');

			// ログインページへリダイレクト
			navigate('/login')
		})
      
    }, []);

}

export default Logout
```


## ※ サーバー側から 401 エラーを返された際には
「結論」の中では、ログアウト時のみ、認可情報の削除を行なっていますが、実際には、すべてのAPIコールのエラー時（401エラー）にて、認可情報の削除を行います。（紙面の都合上、割愛させていただきました。）
なぜなら、サーバー側で認証が切れているのに、フロント側にて、認証状態が続くのは、ユーザー状態管理として不適切だからです。

# 4. 補論：SessionStorage で状態管理を行うのはよくない？

SessonStorage に認証情報を格納するのは、よくないといった意見が散見されます。
SessionStorage や LocalStorage などの WebStorage に置かれた情報は JavaScript を用いて簡単に取り出したり、設定することができるからです。

たしかに、個人情報を含む JWT などの情報を置くことには、僕も反対です。
ただ、今回置くのは、権限情報のみです。

- 情報を盗まれたところで、個人情報を含まないため、問題ない。
- 恣意的に権限情報を SessionStorage に設定された場合、ログインページ以外へアクセスを許してしまうが、API 側へのアクセスを許すわけではないので、情報は取られないので問題ない。（API 側へのアクセスは Cookies に JWT を設定するのが望ましいと考えているが、別の話なので割愛）

上記の理由から、SessionStorage を用いて状態管理を行うことは問題ないと考えております。

# 5. おわりに
最後までお読みいただきありがとうございました。

本記事は正確な内容となるように、可能な限り、調査・表現をしていますが、誤植などが含まれる可能性がございます。
もし、お気づきになられた場合には、ご指摘いただけますと幸いです。