react+axiosで認証ページ制御を扱う

SPA環境におけるページ制御をreact, redux, saga, axiosで実装してみた
※Frontのみ扱います

## 環境
- react:16.12
- react-redux: 7.1.3
- react-router-dom: 5.1.2

---
## API仕様
- APIリクエスト時、認証失敗した場合は401を返す

---
## できあがり要件
- メンバー用ページは認証成功したユーザーのみ表示可能
- ログイン後に認証が切れた場合、メンバー用ページが表示できないようにする

---
## 実装
### あらすじ
* ページに基本となるルーティングを作成する
* 認証ルート配下はAuthコンポーネントで認証制御
* 認証状態はredux, redux-sagaを利用して管理
* requestモジュールから401を検知してreact-routerで遷移

---
#### ディレクトリ構成
```bash
user
├── api.ts 　//api通信用
├── index.ts
├── redux.ts　// いわゆるAction,Reducer等
└── saga.ts　// Saga
```

#### ルーティング
```typescript:router.tsx
import React from "react";
import { BrowserRouter as Router, Route, Switch } from "react-router-dom";

const AppRouter: React.FC = () => (
  <Router>
    <Switch>
      <Route exact path='/' component={Top} />
      {/* 認証ルート */}
      <Route path='/member'>
        <Auth>
          <Switch>
            <Route path="/member/signed" component={Signed} />
            <Route component={NotFound} />
          </Switch>
        </Auth>
      </Route>
      <Route component={NotFound} />
    </Switch>
  </Router>
);
```

#### Authモジュール
* 認証状態取得前なら取得取得するまでcircular表示。認証できない場合はサインインページへ遷移

```typescript:auth.tsx

const Auth = ({ user: { isSignedIn, isFetched }, fetchInfo, children }) => {
  if (!isFetched) {
    fetchInfo();
  }
  return (
    <>
      {isFetched ? (
        <Fetched isSignedIn={isSignedIn} children={children} />
      ) : (
        <Fetching />
      )}
    </>
  );
};

const Fetching = () => (
  <div className={styles.center}>
    <Circular />
  </div>
);
const Fetched = ({ isSignedIn, children }) =>
  isSignedIn ? children : <Redirect to='/sign_in' />;

export default connect(mapStateToProps, mapDispatchToProps)(Auth);

```

#### redux, redux-saga関連
* 認証情報取得。認証通信がエラーの場合はSIGNED_IN = falseをkeepし、authモジュールによってページ遷移させる

```typescript:redux.ts
const signedIn = () => {
  return {
    type: SIGNED_IN
  };
};
const signedOut = () => {
  return {
    type: SIGNED_OUT
  };
};
const signOut = () => ({ type: SIGN_OUT });
const fetched = () => ({ type: FETCHED });
const fetchInfo = payload => ({ type: FETCH_INFO, payload });
const resetState = () => ({ type: RESET_STATE });
```

```typescript:saga.ts
function* fetchInfo() {
  const isSuccess = yield call(api.fetchInfo);

  if (isSuccess) {
    yield all([put({ type: SIGNED_IN }), put({ type: FETCHED })]);
  } else {
    yield all([put({ type: FETCHED }), put({ type: SIGNED_OUT })]);
  }
}
```

```typescript:api.ts
export const fetchInfo = async () => {
  let isSignedIn = true;
  const requester = requestManager.get();

  await requester.get(API_PATH.AUTH_INFO).catch(err => {
    isSignedIn = false;
  });

  return isSignedIn;
};
```

### その他通信時に認証が切れた場合のハンドリング
* axiosをwrapしたrequestモジュールを作成し、認証制御機能を組み込む

```typescript:utils/request.ts
import axios, { AxiosRequestConfig, AxiosResponse, AxiosInstance } from "axios";
import { mapDispatchToProps as userActions } from "../modules/user";
import { AllState } from "../store";

class RequestManager {
  private store?: AllState;
  private requester?: AxiosInstance;
  setStore(store: AllState) {
    this.store = store;
  }
  getInstance(): AxiosInstance {
    if (!this.store) {
      throw new Error("store is not initialized");
    }
    if (!this.requester) {
      this.requester = this._getBaseInstance();
    }

    return this.requester;
  }
  private _getInstance(): AxiosInstance {
    const instance = axios.create({
      baseURL: "http://localhost:3000",
      headers: { "content-type": "application/json" },
      withCredentials: true
    });

    instance.interceptors.response.use(response => {
      const { status } = response;

      if (status === 401) {
        this.store.dispatch(userActions.resetState());
      }
      return response;
    });

    return instance;
  }
}

export default new RequestManager();

```
---

このようにすれば、RequestManagerから生成されたaxiosインスタンス経由でリクエスト時に401の場合、isSignedIn: falseに変更され、認証ルート内の場合はサインインページへ遷移させられる