テックブログ

  1. React
  2. 326 view

React+ReduxアプリケーションにJWT認証を導入する完全ガイド【ソースコードあり】

はじめに

本記事では、React+ReduxアプリケーションにJWT認証を導入する方法を説明します。ログインページとダッシュボードページで構成されるシンプルなアプリケーションを実際に作りながら認証つきのセキュアなSPAアプリケーションを作る方法を解説します。

少し長いですが、全手順解説&GitHubに実際に動作するソースコードがあるので、わからないところがあっても最後まで進められるようになっています!

作るもの

ソースコード

動作確認やわからないところの確認のため実際に動くソースコードを用意しました!

フロントエンド:React+Redux+JWT

バックエンド:JWTのモックサーバ

使うもの

Node.js

Postman

お持ちでない方はリンクよりゲットしてください!

バックエンドの準備

バックエンドの起動

JWT認証のモックサーバを用意しました。下記の手順でクローンして起動してください。

git clone https://github.com/windii-legend/express-jwt-mock.git

cd express-jwt-mock

sudo node index.js

バックエンドの動作

バックエンドの仕様について説明します。実装については説明しませんが、
Express【Node.js】のAPIにJWT認証を導入しよう!
の記事で詳しく説明しているので興味ある方はぜひどうぞ!

バックエンドはlocalhost:8080で立ち上がります。
仕様といっても単純で以下の2つのエンドポイントを持ちます。

[POST] /api/authenticate

ユーザ名とパスワードをリクエストするとトークンが返ってくるエンドポイント。

リクエストの形は以下のような感じ。

{
  "name": "tanaka",
  "password": "password",
}

レスポンスの形は以下のような感じ。

{"token": "abscsgadjfkajfXXaajfdXXXXXX"}

[GET] /api/me [認証あり]

HeaderAuthorization: Bearer [JsonWebToken]を付与すると以下のようなレスポンスを返す。

{
    "id": 2,
    "name": "jiro",
    "nickname": "鈴木次郎"
}

実際にPostmanを使ってリクエストしてみた結果がこちら。

特に2つ目がポイントで、HeadersタブでAuthorizationBearer XXXXXXXXXXXX
と指定しています。XXXXXの部分には先ほど取得したJWTトークンが入ります。

ちなみに

{
  "name": "tanaka",
  "password": "password"
}

でトークンが返ってきます。

少し長くなりましたが以上でJWT認証サーバの説明は終わりです。フロントエンドに取り掛かりましょう!!

フロントエンドの動作確認

すぐに動作を確認したい場合は以下の手順でクローン&起動してください。
(先ほどのサーバを起動しておいてください。)

git clone https://github.com/windii-legend/react-jwt-example.git
cd react-jwt-example
npm install
npm start

うまく動いたでしょうか?

それでは、フロントエンドの実装をはじめて行きましょう。本記事の1ページ目はjwt認証を実装する前の設定をひたすらやります。JWT認証の実装方法だけをやりたい方は

git clone https://github.com/windii-legend/react-jwt-starter-kit.git

でソースを取得して、2ページ目にGO!!

プロジェクトの作成

まずはプロジェクトを作成しましょう。

create-react-app react-jwt-example

プロジェクトを作成したら以下のようなディレクトリ構成を作ります。
(React+Reduxの一般的な構成です。)

ReduxとRouterの設定

【5分でできる】ReactにRouterとRudexをサクッと設定する方法
こちらの記事にまとめておいたので手順に沿ってルーティングを設定して行きます。
(あまり詳しくは説明しないのでわからないところがあったら上記記事を参考にしてください!
m(_ _)mゴメン)

Reduxの設定

npm install --save redux react-redux redux-logger

src/reducers配下にindex.jsという名前のファイルを作成して以下のようにしてください。

export const dumb = (state = {}) => state;

src/index.jsを以下のようにしてください。

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import registerServiceWorker from './registerServiceWorker';

import { createStore, combineReducers, applyMiddleware } from 'redux';
import { Provider } from 'react-redux';

import * as reducers from './reducers';

import logger from 'redux-logger';

// Storeを作成する
const store = createStore(
  combineReducers(reducers),
  // applyMiddleware関数でredux-loggerを設定
  applyMiddleware(logger)
);

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>
, document.getElementById('root'));
registerServiceWorker();

Routerの設定

npm install --save react-router-dom [email protected] history

src/index.jsを以下のように書き換えます。

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import registerServiceWorker from './registerServiceWorker';

import { createStore, combineReducers, applyMiddleware } from 'redux';
import { Provider } from 'react-redux';

import * as reducers from './reducers';

// ルーターの設定
import createBrowserHistory from 'history/createBrowserHistory';
import { routerReducer, routerMiddleware, ConnectedRouter } from 'react-router-redux';

import logger from 'redux-logger';

// historyインスタンスを作成する処理追加
const history = createBrowserHistory();

// Storeを作成する
const store = createStore(
  combineReducers({
    ...reducers,
    router: routerReducer
  }),
  // applyMiddleware関数でredux-loggerを設定
  applyMiddleware(
    routerMiddleware(history),
    logger
  )
);

ReactDOM.render(
  <Provider store={store}>
    {/*ConnectedRouterコンポーネントを追加*/}
    <ConnectedRouter history={history}>
      <App/>
    </ConnectedRouter>
  </Provider>
, document.getElementById('root'));
registerServiceWorker();

ここまでできたら一度

npm start

で起動して見ましょう!エラーが表示されなければReduxとRouterの設定は完了です!

画面の作成

画面の見栄えが悪いとやる気が出にくいのである程度綺麗なUIを作ります。今回はMaterial UIをつかってみましょう。

npm install @material-ui/core --save
npm install @material-ui/icons --save

src/components/以下にLogin.js,Dashboard.jsHeader.jsを作成して以下のようにしましょう。こちらはレイアウトの問題だけなのでコピーアンドペーストで問題ありません。

src/components/Login.js

import React, { Component } from 'react';

import Avatar from '@material-ui/core/Avatar';
import Button from '@material-ui/core/Button';
import CssBaseline from '@material-ui/core/CssBaseline';
import FormControl from '@material-ui/core/FormControl';
import Input from '@material-ui/core/Input';
import InputLabel from '@material-ui/core/InputLabel';
import LockIcon from '@material-ui/icons/LockOutlined';
import Paper from '@material-ui/core/Paper';
import Typography from '@material-ui/core/Typography';
import withStyles from '@material-ui/core/styles/withStyles';
import Header from './Header';

const styles = theme => ({
  layout: {
    width: 'auto',
    display: 'block',
    marginLeft: theme.spacing.unit * 3,
    marginRight: theme.spacing.unit * 3,
    [theme.breakpoints.up(400 + theme.spacing.unit * 3 * 2)]: {
      width: 400,
      marginLeft: 'auto',
      marginRight: 'auto',
    },
  },
  paper: {
    marginTop: theme.spacing.unit * 8,
    display: 'flex',
    flexDirection: 'column',
    alignItems: 'center',
    padding: `${theme.spacing.unit * 2}px ${theme.spacing.unit * 3}px ${theme.spacing.unit * 3}px`,
  },
  avatar: {
    margin: theme.spacing.unit,
    backgroundColor: theme.palette.secondary.main,
  },
  form: {
    width: '100%', // Fix IE11 issue.
    marginTop: theme.spacing.unit,
  },
  submit: {
    marginTop: theme.spacing.unit * 3,
  },
  alert: {
    color: 'red',
    fontSize: 14
  }
});

class Login extends Component {

  constructor(props) {
    super(props);
    this.state = {
      username: '',
      password: '',
    }
    this.handleChange = this.handleChange.bind(this);
    this.handleSubmit = this.handleSubmit.bind(this);
  }

  handleChange(e) {
    const { name, value } = e.target;
    this.setState({ [name]: value });
  }

  handleSubmit(e) {
    e.preventDefault();
    const { username, password } = this.state;
  }
  render(){
    const { classes } = this.props;
    return (
      <React.Fragment>
        <Header menu="ログイン"/>
        <CssBaseline />
        <main className={classes.layout}>
          <Paper className={classes.paper}>
            <Avatar className={classes.avatar}>
              <LockIcon />
            </Avatar>
            <Typography variant="headline">ログインしてはじめよう!</Typography>
            {this.props.error ?
              <p className={classes.alert}>ユーザ名またはパスワードが正しくありません。</p>
              : ''
            }
            <form className={classes.form}>
              <FormControl margin="normal" required fullWidth>
                <InputLabel htmlFor="email">ユーザー名またはメールアドレス</InputLabel>
                <Input id="email" name="username" autoFocus onChange={this.handleChange}/>
              </FormControl>
              <FormControl margin="normal" required fullWidth>
                <InputLabel htmlFor="password">パスワード</InputLabel>
                <Input
                  name="password"
                  type="password"
                  id="password"
                  autoComplete="current-password"
                  onChange={this.handleChange}
                />
              </FormControl>
              <Button
                type="submit"
                fullWidth
                variant="raised"
                color="primary"
                className={classes.submit}
                onClick={this.handleSubmit}
              >
                ログイン
              </Button>
            </form>
          </Paper>
        </main>
      </React.Fragment>
    );
  }
}
export default withStyles(styles)(Login);

src/components/Dashboard.js

import React, { Component } from 'react';
import { withStyles } from '@material-ui/core/styles';
import Paper from '@material-ui/core/Paper';
import Typography from '@material-ui/core/Typography';
import Header from './Header';
const styles = theme => ({
  root: {
    ...theme.mixins.gutters(),
    paddingTop: theme.spacing.unit * 2,
    paddingBottom: theme.spacing.unit * 2,
  },
  paper: {
    width: '50%',
    margin: '0 auto',
    minWidth: 300,
    marginTop: theme.spacing.unit * 8,
    display: 'flex',
    flexDirection: 'column',
    alignItems: 'center',
    padding: `${theme.spacing.unit * 2}px ${theme.spacing.unit * 3}px ${theme.spacing.unit * 3}px`,
  },
});

class Dashboard extends Component {
  render() {
    const { classes} = this.props;
    return (
      <div>
        <Header menu="ログアウト" onClick={this.props.logout}/>
        <Paper className={classes.paper} elevation={1}>
          <Typography variant="headline" component="h3">
            <strong>田中太郎</strong>さん、ダッシュボードへようこそ!
          </Typography>
          <Typography component="p">
            さあ、JWT認証をマスターしたらSPAアプリケーションを今すぐ開発しましょう!
          </Typography>
        </Paper>
      </div>
    );
  }
}


export default withStyles(styles)(Dashboard);

src/components/Header.js

import React from 'react';
import AppBar from '@material-ui/core/AppBar';
import Toolbar from '@material-ui/core/Toolbar';
import Typography from '@material-ui/core/Typography';
import Button from '@material-ui/core/Button';
import IconButton from '@material-ui/core/IconButton';
import MenuIcon from '@material-ui/icons/Menu';

import { withStyles } from '@material-ui/core/styles';

const styles = {
  grow: {
    flexGrow: 1,
  },
  menuButton: {
    marginLeft: 12,
    marginRight: 20
  }
}

function header(props) {
  const { classes } = props;
  return (
    <AppBar position="static">
      <Toolbar>
        <IconButton className={classes.menuButton} color="inherit" aria-label="Menu">
          <MenuIcon />
        </IconButton>
        <Typography variant="title" color="inherit" className={classes.grow}>
          React + ReduxでJWT認証を導入しよう!
        </Typography>
          <Button color="inherit" onClick={props.onClick}>{props.menu}</Button>
      </Toolbar>
    </AppBar>
  );
}

export default withStyles(styles)(header);

次にsrc/containers/配下にLogin.jsDashboard.jsを作成して以下のようにします。

src/containers/Login.js

import { connect } from 'react-redux';
import Login from '../components/Login';


const mapStateToProps = (state, ownProps) => ({});

const mapDispatchToProps = dispatch => ({});

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

src/containers/Dashboard.js

import { connect } from 'react-redux';
import Dashboard from '../components/Dashboard';

const mapStateToProps = (state, ownProps) => ({});

const mapDispatchToProps = dispatch => ({});

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

src/App.jsを以下のように書き換えます。

import React, { Component } from 'react';
import { Route} from 'react-router-dom';

import { withStyles } from '@material-ui/core/styles';

import Login from './containers/Login';
import Dashboard from './containers/Dashboard';


const styles = {
  root: {
    flexGrow: 1,
  },
}

class App extends Component {
  render() {
    const { classes } = this.props;
    return (
      <div className={classes.root}>
        <Route path="/dashboard" component={Dashboard}></Route>
        <Route path="/" exact={true} component={Login}></Route>
      </div>
    );
  }
}

export default withStyles(styles)(App);

この状態で

npm start

してみて、localhost:3000localhost:3000/dashboardにアクセスしてみてください。画面が表示されれば成功です。

うまくいかなくても問題ありません!これまでのソースは以下のようにすれば取得できます。

git clone https://github.com/windii-legend/react-jwt-starter-kit.git
cd react-jwt-starter-kit
npm install

お疲れ様でした!次のページではいよいよJWT認証の導入しましょう!

The following two tabs change content below.

riri

半年おきくらいにバックエンドとフロントエンドを行ったり来たり。 25歳。 将棋好き。

1

2 3

Reactの最近記事

  1. React+ReduxアプリケーションにJWT認証を導入する完全ガイド【ソースコードあり】…

  2. 【5分でできる】ReactにRouterとRudexをサクッと設定する方法

  3. 【React】create-react-appでエラーが出た時の対処法

  4. React Native入門〜Expoを使ってお手軽開発

  5. Reactのプロジェクトにintercomを導入する

関連記事

コメント

  1. この記事へのコメントはありません。

  1. この記事へのトラックバックはありません。