目次
- モチベーション
- 環境
- 認証の手法(JSON Web Token)
- 流れ
- クライアント側での認証の制御
- ログイン認証の処理
- まとめ
- 参考記事
モチベーション
ReduxをフレームワークとしてReactを使ったSPAを作成していたときに、ログイン認証の処理で結構悩んで、悩み抜いた結果の知見を共有といった感じです。
まだまだSPA勉強中の身のため、間違ったやり方をしている部分があるかもしれませんが、そのときはご指摘いただけるとありがたいです。
なお、今回のサンプルソースは以下においてあります。細かい解説をしていない部分は、実際のソースを見て確認してください。
nabeliwo/jwt-react-redux-auth-example
※サーバーサイドレンダリングには対応していません。
環境
サーバーサイド
Node 6.1.0
hapi 13.4.1 (フレームワーク)
※今回は特にサーバーサイドのフレームワークに依存しないのでhapi以外のものに変えるのも簡単です。
クライアントサイド
React 15.0.2
Redux 3.5.2
(ReduxのMiddlewareとして、redux-sagaを使っています。)
認証の手法(JSON Web Token)
今回は、セッションとクッキーでのログイン認証のやり方ではなく、JSON Web Token(以下JWT)を使った認証を行います。
JWTを使った認証では、サーバー側でユーザー情報を元にsecretKey(サーバー側のconfigとして用意)をキーとしてトークンを生成して、それをクライアントに渡してクライアント側でローカルストレージにトークンを保存します。
そのトークンを所持していることがログインしている証になります。クライアントが保持しているトークンをサーバー側に渡して、そのトークンをsecretKeyをキーとして復号することでユーザー情報を取り出すことができます。
流れ
基本的な流れを説明します。
初期ログイン時
- 【クライアント】ログインフォームをsubmit(nameとpassを送信)
- 【サーバー】nameとpassを元にDB接続してユーザーが見つかったら、JWTを使って取得したユーザーのデータを元にしてsecretKeyをキーにしてトークンを生成
- 【サーバー】生成したトークンとユーザー情報をクライアントに返す
- 【クライアント】受け取ったトークンはLocalStorageに保存。ログイン処理終了
ログイン後の全てのリクエストはリクエストヘッダーにトークンを乗せて、サーバーはそこから取り出したユーザーの情報を元に処理をする。
ログイン後のリロード時
「ログイン後のリロード時」というのはどういうことかという話なんですが、SPAではログイン中であることをユーザー情報がstateに入っているかどうかで判断します。
リロードをするとstateが初期化されてユーザー情報が消えてしまうため、初期化時にサーバーにリクエストを送ってユーザー情報を取得しないといけないです。
- 【クライアント】LocalStorageから取り出したトークンをリクエストヘッダーに乗せてGETリクエストを送信
- 【サーバー】トークンをsecretKeyを使って復号してユーザーのIDを元にDBからユーザー情報を取得してクライアントに返す
- 【クライアント】ユーザー情報を受け取ってログイン処理終了
Redux & React wayに乗せて上記の2つの処理の流れを作っていきます。
クライアント側での認証の制御
上記の流れとは別軸で、SPAの中でログインしている場合とそうでない場合とで、表示できるページを切り分けなければなりません。
今回はログインしてユーザー情報を取得した場合、ユーザー情報をstateとして保存します。ページ遷移のたびにそのstateを見てログイン済みかどうか判断し、適切なページに遷移させます。
実際のソースは以下のようになります。
// 省略..
render(
<Provider store={store}>
<Router history={history}>
<Route path="/" component={App}>
<Route component={UserOnly}> // ログイン後のページ
<IndexRoute component={Index} />
</Route>
<Route component={GuestOnly}> // ログイン前のページ
<Route path="/login" component={Login} />
</Route>
</Route>
</Router>
</Provider>,
document.getElementById('app')
);
react-routerでルーティングの処理をしています。
ログイン後にしか入れないページとログイン前でしか入れないページを切り分けて、それぞれをUserOnlyとGuestOnlyというコンポーネントで包みます。
各コンポーネントの中身は以下のようになります。
// 省略...
class UserOnly extends Component {
static contextTypes = {
router: PropTypes.object.isRequired
}
componentWillMount() {
this.guestWillTransfer(this.props, this.context.router);
}
componentWillUpdate(nextProps) {
this.guestWillTransfer(nextProps, this.context.router);
}
guestWillTransfer(props, router) {
if (!props.auth.isLoggedIn) {
router.replace('/login');
}
}
render() {
return <div>{this.props.children}</div>;
}
}
// ...省略
ライフサイクルの中で、componentWillMountとcomponentWillUpdateのときに条件分岐の処理を走らせます。(初期描画と遷移の度に走らせる)
isLoggedInというステートのbooleanを見てログインしていない場合はログインページに飛ばします。
GuestOnlyはUserOnlyが逆になっているだけです。
// 省略...
class GuestOnly extends Component {
static contextTypes = {
router: PropTypes.object.isRequired
}
componentWillMount() {
this.userWillTransfer(this.props, this.context.router);
}
componentWillUpdate(nextProps) {
this.userWillTransfer(nextProps, this.context.router);
}
userWillTransfer(props, router) {
if (props.auth.isLoggedIn) {
router.replace('/');
}
}
render() {
return <div>{this.props.children}</div>;
}
}
// ...省略
GuestOnlyコンポーネントの条件分岐では、ログイン済みだった場合はログイン後のページに飛ばします。
この手法でクライアント側のログイン済みかどうかでのページ分岐を切り分けます。
react-routerのレイヤーとしては、
- APPというレイヤーでロード時にLocalStorageを見てログイン済みかどうかのstateの更新(後述)
- UserOnly,GuestOnlyのレイヤーでログインのステータスに適したページに飛ばす
- 実際のページのレイヤーでコンポーネントを表示
という形になります。
ログイン認証の処理
ここからは、JWTを使ったログイン認証に関しての処理になります。
初期ログイン時
ログインフォームをsubmitする
// 省略...
class Login extends Component {
handleSubmit(e) {
const target = e.target;
e.preventDefault();
this.props.dispatch(fetchUser({
name: target.name.value.trim(),
pass: target.password.value.trim()
}));
}
renderSubmit() {
return this.props.auth.isFetching ? <Loading /> : <input type="submit" value="Send" />;
}
render() {
const { auth } = this.props;
return (
<div>
<h1>Log in</h1>
<form onSubmit={::this.handleSubmit}> // === this.handleSubmit.bind(this)
<ul>
<li>
<p>name</p>
<p><input type="text" name="name" required /></p>
</li>
<li>
<p>Password</p>
<p><input type="password" name="password" required /></p>
</li>
</ul>
{auth.error &&
<p>{auth.error}</p> // エラーがあった場合、エラー文言を表示する
}
{this.renderSubmit()} // isFetchingがtrueのときはsubmitボタンではなくローディングを表示する
</form>
</div>
);
}
}
// ...省略
formがsubmitされたら、fetchUserというアクションをdispatchします。fetchUserアクションが起きるとreducerでisFetchingというstateをtrueに変更するため、submitボタンがローディングのアニメーションに変わります。
同時にnameとpassを渡します。fetchUserアクションがdispatchされると、データのfetchをするので非同期通信が発生します。非同期通信のときは最初はredux-thunkを使っていましたがなかなか辛かったので、今回はredux-sagaを使っています。
redux-sagaについては以前書いた記事で触れています。(redux(Middleware: redux-saga)プロジェクトのテンプレートを作った)
// 省略...
export function* handleLogin() {
while (true) {
const action = yield take(`${fetchUser}`);
const { payload, err } = yield call(superFetch, {
url: '/api/login/',
type: 'POST',
data: action.payload
});
if (!payload && err) {
yield put(failFetchingUser(String(err).split('Error: ')[1]));
continue;
}
const jwt = payload[0].jsonWebToken;
localStorage.setItem('jwt', jwt);
yield put(login(Object.assign({}, payload[0], { jwt })));
}
}
// ...省略
fetchの際にsuperFetchという関数を使ってますが、これは「fetchAPIはエラーが起きてもthenで返ってくる」という仕様がとてもつらかったために作ったラッパーです。
fetchに失敗したらfailFetchingUserというアクションを起こしてエラー文言を渡してreducerでstateを更新してエラー文言を表示させています。
ログイン成功時の処理は後述します。
fetchでリクエストを送った際のサーバー側の記述は以下です。
受け取った情報を元にトークンを生成し、ユーザー情報と一緒にクライアントに返す
// 省略...
{
path: '/api/login/',
method: 'POST',
handler: (request, reply) => {
const name = request.payload.name;
const pass = request.payload.pass;
// Use DB connection instead of dummyUser in production.
if (dummyUser.name === name && dummyUser.pass === pass) {
const jsonWebToken = jwt.sign({
id: dummyUser.id,
mail: dummyUser.mail
}, secretKey);
return reply([Object.assign({}, dummyUser, { jsonWebToken })]);
}
const err = Boom.badImplementation('name or password is not found', {
message: '入力されたユーザー名やパスワードが正しくありません。確認してからやりなおしてください。'
});
err.output.payload = Object.assign({}, err.output.payload, err.data);
return reply(err);
}
},
// ...省略
ここでは実際にDBに接続する処理は書いておらず、dummyUserというオブジェクトの情報との比較になっていますが、実際はnameとpassを元にDBからユーザーを取得します。
ユーザーが見つかった場合は、第一引数にトークンの元にしたいオブジェクト、第二引数にsecretKeyを入れてJWTのsignを実行します。secretKeyはサーバー側のconfigなどであらかじめ設定しておきます。
取得したユーザー情報と生成したトークンを一緒にしてクライアントに返します。
受け取ったトークンをLocalStorageに保存してloginアクションを起こしてログイン終了
先ほどのsagaのauth.jsと被る内容ですが、以下のようになっています。
// 省略...
const jwt = payload[0].jsonWebToken;
localStorage.setItem('jwt', jwt);
yield put(login(Object.assign({}, payload[0], { jwt })));
// ...省略
サーバーから返ってきたトークンをlocalStorageに保存をしたのち、loginアクションを発行してユーザー情報を渡しています。
reducerでloginアクションを待ち受けています。
// 省略...
[login]: (state, payload) => Object.assign({}, state, {
isPrepared: true,
isLoggedIn: true,
user: {
id: payload.id,
name: payload.name,
pass: payload.pass,
},
isFetching: false,
error: undefined,
jwt: payload.jwt
}),
// ...省略
isLoggedInをtrueにして、ユーザー情報を入れています。
これで認証用のコンポーネントを通って、ログイン後のページに遷移します。
ログイン後のリロード時
ログインしているかどうかはstateを見て判断しているため、一度リロードをしてしまうとstateが初期化されてしまうのでログインしていないということになってしまいます。
それを防ぐために、一番最初にlocalStorageの中身を見てログイン処理を走らせます。
LocalStorageから取り出したトークンをリクエストヘッダーに乗せてGETリクエストを送信
// 省略...
class App extends Component {
componentWillMount() {
this.props.dispatch(fetchLoginState());
}
handleLogout() {
this.props.dispatch(clickLogout());
}
render() {
const { auth, children } = this.props;
return auth.isPrepared ? (
<div>
<Header
auth={auth}
handleLogout={::this.handleLogout}
/>
{children}
</div>) :
<Loading />; // 初期化時はローディングだけ表示する
}
}
// ...省略
初期化時は、stateのisPreparedがfalseになっているため、APPコンポーネント内の条件分岐により、ローディング画面しか表示されません。
componentWillMountのタイミングでfetchLoginStateというアクションをdispatchします。fetchLoginStateアクションはsagaで待ち受けています。
// 省略...
export function* handleFetchLoginState() {
while (true) {
yield take(`${fetchLoginState}`);
const jwt = localStorage.getItem('jwt');
if (jwt) {
const { payload, err } = yield call(superFetch, {
url: '/api/login/',
type: 'GET',
custom: {
headers: {
authorization: `Bearer ${jwt}`
}
}
});
if (payload && !err) {
yield put(login(Object.assign({}, payload[0], { jwt })));
continue;
}
}
yield put(failFetchingLoginState());
}
}
// ...省略
fetchLoginStateアクションが発生したらlocalStorageからトークンを取得して、fetchでリクエストを送ります。
そのときリクエストヘッダーにトークンをのせます。
localStorageにトークンが存在しなかった場合や、サーバー側でエラーが発生した場合はfailFechingLoginStateアクションが発生し、isPreparedがtrueになり、ログイン画面に遷移します。
成功した場合はユーザー情報を受け取ってloginアクションを起こしてログイン処理が完了します。
secretKeyを使ってトークンを復号してDBからユーザー情報を取得して返す
サーバー側でリクエストを受け取った際の処理になります。
// 省略...
{
path: '/api/login/',
method: 'GET',
handler: (request, reply) => {
const jsonWebToken = request.headers.authorization.split(' ')[1];
jwt.verify(jsonWebToken, secretKey, (err, decode) => {
if (err) {
return reply(Boom.badImplementation(String(err)));
}
// Use DB connection instead of dummyUser in production.
if (dummyUser.id === decode.id) {
return reply(dummyUser);
}
return reply(Boom.badImplementation('User is not found'));
});
}
}
// ...省略
リクエストヘッダーからトークンを取得してJWTのverifyを仕様して復号します。その際、signで生成したときと同じsecretKeyをキーとして使います。
成功するとトークンの元にしたオブジェクトを取り出すことができます。
JWTの利点として、トークンにユーザー情報をそのまま押し込めることができるため、復号したユーザー情報をそのまま返せばDB接続を挟まずに済むのですが、そうなると仮にユーザーが削除済みだったとしても削除済みのユーザーでログインすることが可能になってしまうため、結局IDを元にDBからユーザー情報を取得するべきという判断にいたりました。
このサンプルではdummyUserとの比較になってますが、実際はここでDBから取得する処理を記述します。
クライアント側がユーザー情報を受け取ったらloginアクションを発行するので、ログインが完了します。
以上で認証の処理は全て終わりです。
まとめ
セキュリティー的にどうかとか一応しっかり考えて作った処理の流れになります。
JWTを使えば、セッションとクッキーで認証をしていたときよりは簡潔に書けるかと思います。SPAでの認証の制御の仕方もわりとわかりやすい方法になったかなと思っています。
冒頭でも書きましたが、まだまだSPA勉強中の身のため、間違ったやり方をしている部分があるかもしれませんが、そのときはご指摘いただけるとありがたいです。