SPAでのログイン処理のやりかた


## 概要

こちらの記事の続きです。
[SPAの静的ファイルのデプロイの仕方](http://qiita.com/uryyyyyyy/items/a4b97709a019af1242be)

SPAでも、大体のアプリはユーザー登録やログイン機能があると思います。
その際に、SPAでどうやってログイン済みか否かを判別するか、その際のルーティングをどうするかについて正解がないように思うので、自分なりのやり方を共有します。


## 環境

- NodeJS 5.X~
- React 15.1
- TypeScript 1.8

全体の構成はこちらをご覧ください。
https://github.com/uryyyyyyy/react-redux-sample/tree/spa-auth


## ゴール

- ログイン済みであれば、ログイン画面にアクセスしてもホーム画面にリダイレクトされる
- ログイン済みでなければ、どのページにアクセスしてもログイン画面にリダイレクトされる
- ログイン済みでない場合、アクセスしたページがレンダリングされる前にリダイレクトされる
- 操作中にログインが切れた場合、ログイン画面にリダイレクトされる。

ことを満たすように考えます。

## 実装

手順は以下のようになります。

- エントリポイント（html）を2つに分ける
  - ログイン画面（/login）と、他画面で分けます。
- ホーム（その他）の画面表示前にログイン済みかを確認する
  - ログイン済みならそのまま画面を表示する
  - 未ログインならログイン画面にリダイレクトする
- ログイン画面表示前に、ログイン済みかを確認する
  - ログイン済みならそのまま画面を表示する
  - 未ログインならログイン画面にリダイレクトする
- ログイン後に、APIから401（UnAuthorized）が返ってきたらログイン画面に戻す

### 全体像

```
./
├── index.html
├── login.html
└── public
   ├── bundle
   │   ├── common.js
   │   ├── common.js.map
   │   ├── login.js
   │   ├── login.js.map
   │   ├── main.js
   │   └── main.js.map
   ├── images
   │   └── check.png
   └── libs
       └── 3rd-party-library.js
```

このような構成を目指します。

### エントリポイント（html）を2つに分ける

（１画面でも頑張れば問題ないかもしれませんが、ここはルーティングも区別したいので分けた方が楽だと思います。）

webpackの複数エントリポイントとcommonsPluginを使います。

```js:webpack.config.dev.js
var webpack = require('webpack');
var commonsPlugin = new webpack.optimize.CommonsChunkPlugin('common.js');

module.exports = {
  entry: {
    login: './src/Login.tsx',
    main: "./src/Index.tsx"
  },
  output: {
    path: 'public/bundle',
    filename: '[name].js'
  },
  plugins: [commonsPlugin],
```

このように書くと、Login.tsxとIndex.tsxがそれぞれlogin.js, main.jsにバンドルされ、共通部分はcommon.jsにまとめられます。

あとはhtml側で、

```html:index.html
<script src="public/bundle/common.js"></script>
<script src="public/bundle/main.js"></script>
```

```html:login.html
<script src="public/bundle/common.js"></script>
<script src="public/bundle/login.js"></script>
```

のように読めばOKです。

### ホーム（その他）の画面表示前にログイン済みかを確認する

まず、APIリクエスト時に401が返ってきたらログイン画面にリダイレクトするという処理を共通化してみます。

```ts:HttpClient.tsx
import * as axios from "axios";
import {IMessageJson} from "../Models";

const config: Axios.AxiosXHRConfigBase<any> = {
    headers: {
        'X-Requested-With': 'XMLHttpRequest',
        'Accept': 'application/json'
    }
};


export function getRequest<T>(url: string,
                              _successCB: (val: T) => void,
                              _failCB: () => void): Axios.IPromise<any> {

    function successCB(errXHR: Axios.AxiosXHR<T>): void {
        _successCB(errXHR.data)
    }

    function failCB(errXHR: Axios.AxiosXHR<IMessageJson>): void {
        _failCB();
        if (errXHR.status === 401) {
            alert("認証に失敗しました。ログイン画面に戻ります");
            location.href = "/login";
            return;
        }

        if (!errXHR.data) {
            alert("予期せぬ例外が発生しました。ログイン画面に戻ります");
            location.href = "/login";
            return;
        }else{
            //回避可能なエラー（formで必須値が足りてないなど）の場合はmessageを表示する。
            alert(errXHR.data.message);
            return;
        }
    }

    return axios.get(url, config)
        .then(successCB)
        .catch(failCB);
}
```

ここで、カジュアルにalertやredirectを書くとテストが面倒になるという懸念があるのですが、そもそもHTTPリクエストという副作用を持つ処理はActionCreatorの中だけになるはずですし、JsDOMを使えばテストはできたので別に良いかなと思っています。

これを用いて、Index.tsxを以下のように書きます。

```tsx:Index.tsx
function failCB():void {}

function successCB(val: any):void {
    ReactDOM.render(
        <Provider store={store}>
            <Router history={browserHistory}>
                <Route path='/' component={Root} >
                    <Route path={Paths.COUNTER} component={counterRoot} />
                    <Route path="*" component={NotFound} />
                </Route>
            </Router>
        </Provider>,
        document.getElementById('app')
    );
}

getRequest<any>("authCheck", successCB, failCB);
```

authCheckというAPIが401を返したら、ログイン画面へリダイレクトされます。
正常に返ってきたら、そのまま画面を表示します。
（一度画面が表示されると、以降の画面遷移はHistoryAPIでブラウザ内部で処理されるため、何度もauthCheckが飛ぶことはありません。）

### ログイン画面表示前に、ログイン済みかを確認する

次に、ログイン画面です。エントリポイントを分けたので、このようなLogin.tsxから処理が始まります。

```tsx:Login.tsx
import * as React from "react";
import * as ReactDOM from "react-dom";
import * as axios from "axios";

function login(){
    location.href = "/";
}

function failCB():void {
    ReactDOM.render(
        <button onClick={() => login()}>login</button>,
        document.getElementById('app')
    );
}

function successCB(val: any):void {
    alert("login済みです。ホームへ移動します。");
    login()
}

axios.get("authCheck")
    .then(successCB)
    .catch(failCB);
```

ここで、authCheckで認証済みかを確認して、まだであればそのまま表示し、既にあればホームへリダイレクトしています。
ここで注意なのですが、先ほどのHttpClientを使ってしまうと、401が返ってくるとログイン画面に飛ばすことになってるので、無限ループします。（もちろん僕はやらかしました。。。）

### ログイン後に、APIから401（UnAuthorized）が返ってきたらログイン画面に戻す

画面表示時だけでなく、APIを叩いた際にも401が返ってきたらログインへ飛ばして欲しいです。

既にHttpClientの中でその処理を書いているので、ActionCreatorの中でこのように呼ぶだけで処理が完了します。

```ts:ActionCreator.ts
export function fetchAmountAction() {
    return (dispatch: (action: MyAction) => any) => {

        function failCB():void {
            dispatch({ type: ActionTypes.FETCH_FAIL})
        }

        function successCB(amount: IAmount):void {
            dispatch({ type: ActionTypes.FETCH_SUCCESS, amountJson: amount})
        }

        dispatch({ type: ActionTypes.FETCH_REQUEST});
        return getRequest<IAmount>('/api/count', successCB, failCB)
    }
}
```

サンプルのリポジトリでは、fetchAmountActionで分岐を設けていて、失敗するボタンを押すとリダイレクトが走ることが確認できるようにしています。

## まとめ

扱っているもののせいか、すこしややこしいサンプルになってしまいました。。
コードを読んでもよくわからない場合は気軽にコメント頂ければと思います。