React+Djangoでブログを作る5〈ユーザー認証編〉

今回は、ログイン・ログアウト等の部分を作ります。

Djangoバックエンドの準備

ログインとログアウトをトークン認証で行うために修正を行います。

Django REST Framework JWTのインストール

デフォルトのdjango-rest-authでもトークン認証はされているのですが、これをJWT版のトークン認証に変更します。

専用のパッケージが用意されていて、これをインストールして設定する事でdjango-rest-authがJWTを利用するようになります。

$ pip install djangorestframework-jwt

blogress/settings.pyを変更します。ついでにメールアドレス認証にも変更します。

# 追記
ACCOUNT_AUTHENTICATION_METHOD = 'email'
ACCOUNT_EMAIL_REQUIRED = True   
ACCOUNT_USERNAME_REQUIRED = False

REST_USE_JWT = True # 追記

REST_FRAMEWORK = {
    'DEFAULT_PERMISSION_CLASSES': [
        'rest_framework.permissions.IsAuthenticated',
    ],
    'DEFAULT_AUTHENTICATION_CLASSES': [
        'rest_framework_jwt.authentication.JSONWebTokenAuthentication', # 変更
    ],
    'NON_FIELD_ERRORS_KEY': 'detail', # 変更
    'TEST_REQUEST_DEFAULT_FORMAT': 'json' # 変更
}

ルーティングの修正

また、特に理由は無いですがルーティングも少し変更しておきます。

from django.contrib import admin
from django.urls import include, path
from rest_framework.schemas import get_schema_view
from django.views.generic import TemplateView

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/posts/', include('posts.urls')),
    path('api/users/', include('users.urls')),
    path('api/auth/', include('rest_auth.urls')),
    path('api/auth/register/', include('rest_auth.registration.urls')),
    path('schema/', get_schema_view( # スキーマ表示の追加
        title="Blogress",
        description="API for all things …"
    ), name='openapi-schema'),
    path('docs/', TemplateView.as_view( # ドキュメント表示の追加
        template_name='swagger-ui.html',
        extra_context={'schema_url':'openapi-schema'}
    ), name='swagger-ui'),
]

これで、DjangoバックエンドのJWTでのトークン認証の準備が整いました。

Reactフロントエンドの準備

トークン認証なのでバックエンドからトークンが送られてくるわけですが、それをReactで上手く扱えるようにします。

Redux

Reduxは状態管理を行ってくれるフレームワークです。

これがあるとReactが管理するデータが膨大になった時にpropsでデータを親から子へバケツリレーしなくて良くなるようです。

今回はReduxを使って、得られたトークンを保持します。

Redux-Thunk

Redux-ThunkはReduxの機能を非同期的に行ってくれるライブラリです。

トークン取得等の通信を要する処理結果をReduxで管理する場合に必要になるようです。

ReduxとRedux-Thunkのインストール

ReduxとRedux-Thunkをインストールして、ReactとReduxの連携を補助するReact-Reduxをインストールします。

$ npm install redux
$ npm install react-redux
$ npm install redux-thunk

React-Router-DOM

React-Router-DOMはReactのURLとDOMを関連付けてくれるライブラリです。

これを使って、ログインページとトップページのルーティングを行います。

React-Router-DOMのインストール

下記コマンドでインストールを行います。

$ npm install react-router-dom

リファクタリング

これまで書いて来たフロントエンドのコードが少々気になり始めたので、ログインページを作る前にリファクタリングを行います。

まず初めにfrontend/src/App.jsです。

全てのコンポーネントの最上位に位置しているので、ここでは各ページへのルーティングと、ストアやAxiosインスタンス等アプリ内で唯一の物の作成をします。

ついでにfrontend/src/App.jsxにリネームして、frontend/src/components/constants.jsxの内容も統合しましょう。

まだページは作成していませんが、ログインページをfrontend/src/components/pages/LoginPage.jsxに作ることにしてルーティングを先にします。

/**
 * src/App.jsx
 * @file ルーティングやシングルトン定義をする最上位コンポーネント
 */
import React from 'react';
import { BrowserRouter, Route, Switch } from 'react-router-dom';
import Axios from 'axios';

/* Redux関連 */
import { createStore } from 'redux';
import { Provider } from 'react-redux';
import reducer from './modules';

/* ページコンポーネント */
import TopPage from './components/pages/TopPage';
import LoginPage from './components/pages/LoginPage';

/**
 * ブログタイトル
 * @type {string}
 */
export const title = 'My Blogress Life';
/**
 * ブログの説明
 * @type {string}
 */
export const description = 'This is my blog made in simple blog system named Blogress.';
/**
 * コピーライト表示
 * @type {string}
 */
export const copyright = 'Copyright © ' + title + ' ' + new Date().getFullYear() + '.';

/**
 * APIエンドポイント
 * @type {string}
 */
export const endpoint = 'http://localhost:8000/api';

/** Redux用のストア */
export const store = createStore(reducer);

/** ベースのURLが定義されたAxiosインスタンスを作成して使い回す */
export const axios = Axios.create({
  baseURL: endpoint,
  timeout: 1000,
  headers: { "Content-Type": "application/json" },
  data: {},
  responseType: 'json',
});

/* 各ページのルーティング */
const App = () => {
  return(
    <Provider store={store}>
      <BrowserRouter>
        <Switch>
          <Route exact path='/login' component={LoginPage}/>
          <Route exact path='/' component={TopPage}/>
        </Switch>
      </BrowserRouter>
    </Provider>
  );
}

export default App;

ブログタイトルを保持するファイルが変わったので、frontend/src/components/organisms/Header.jsxを修正します。

/**
 * src/components/organisms/Header.jsx
 * @file ヘッダーを表示するコンポーネント
 */
import React, { Fragment } from 'react';
import { makeStyles } from '@material-ui/core/styles';

import { AppBar, Toolbar, Typography } from '@material-ui/core';
import SportsVolleyballIcon from '@material-ui/icons/SportsVolleyball';

import { title } from '../../App';

const useStyles = makeStyles(theme => ({
    offset: theme.mixins.toolbar,
}))

const Header = () => {
    const classes = useStyles();
    return(
        <Fragment>
            <AppBar position='fixed'>
                <Toolbar>
                    <SportsVolleyballIcon />
                    <Typography variant="h6" color="inherit" noWrap>
                        {title}
                    </Typography>
                </Toolbar>
            </AppBar>
            <div className={classes.offset}/>
        </Fragment>
    );
}

export default Header;

コピーライトの文字列も一元化したので、表示するコンポーネントもfrontend/src/components/atoms/Copyright.jsxを作成します。

/**
 * src/components/atoms/Copyright.jsx
 * @file 著作権表示をするコンポーネント
 */
import React from 'react';

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

import { copyright } from '../../App';

const Copyright = () => (
    <Typography variant="body2" color="textSecondary" align="center">
        {copyright}
    </Typography>
);

export default Copyright;

元々コピーライト表示をしていたfrontend/src/components/organisms/Footer.jsxも修正します。

/**
 * src/components/organisms/Footer.jsx
 * @file フッターの表示をするコンポーネント
 */

import React from 'react';
import { makeStyles } from '@material-ui/core/styles';

import Copyright from '../atoms/Copyright';

const useStyles = makeStyles(theme => ({
    footer: {
        backgroundColor: theme.palette.background.paper,
        padding: theme.spacing(6),
    },
}))

const Footer = () => {
    const classes = useStyles();
    return(
        <footer className={classes.footer}>
            <Copyright />
        </footer>
    );
}

export default Footer;

Axiosのインスタンスを作成して使い回すようにしたので、frontend/src/components/pages/TopPage.jsxのAxiosがインスタンスを読み込むように修正します。

/**
 * src/components/pages/TopPage.jsx
 * @file トップページを表示するコンポーネント
 */
import React, { useState, useEffect } from 'react';
import { axios } from '../../App';
import { makeStyles } from '@material-ui/core/styles';

import TopPageTemplate from '../templates/TopPageTemplate';

const useStyles = makeStyles(theme => ({
    page: {
        margin : 60,
    }
}));

const TopPage = () => {
    const classes = useStyles();
    const [posts, setPosts] = useState([]);

    useEffect(() => {
        axios
            .get('/posts/', )
            .then(res=>{setPosts(res.data);})
            .catch(err=>{console.log(err);});
    }, []);

    return(
        <TopPageTemplate className={classes.page} posts={posts} />
    );
}

export default TopPage;

ログインページを作る

シンプルなログインページを作ります。

まずは、ログイン用のフォームを作成します。

これは後々トップページでも小さく開けるように、frontend/src/components/organisms/LoginForm.jsxに作成します。

/**
 * src/components/organisms/LoginForm.jsx
 * @file ログイン時の入力パネルを表示するコンポーネント
 */

import React, { useState } from 'react';
import { makeStyles } from '@material-ui/core/styles';

import { Box, TextField, Button, Checkbox, FormControlLabel, Typography } from '@material-ui/core'
import InputIcon from '@material-ui/icons/Input';

const useStyles = makeStyles(theme => ({
    form: {
        width: '100%',
        marginTop: theme.spacing(1),
    },
    button: {
        width: '100%',
        marginTop: theme.spacing(1),
    },
    checkbox: {
        float: 'right',
    }
}));

const LoginForm = props => {
    const classes = useStyles();
    const [email, setEmail] = useState('');
    const [password, setPassword] = useState('');
    const [showPassword, setShowPassword] = useState(false);
    return (
        <Box width={256} p={6} border={1}>
            <Typography component="h1" variant="h5">ログイン</Typography>
            <form noValidate>
                <TextField className={classes.form} id="emailForm"
                    value={email} onChange={e => setEmail(e.target.value)}
                    label="メールアドレス"/>
                <TextField className={classes.form} id='passwordForm'
                    value={password} onChange={e => setPassword(e.target.value)}
                    label="パスワード"
                    type={showPassword ? '' : 'password'}
                    autoComplete='current-password'/>
                <FormControlLabel className={classes.checkbox} label="パスワードを表示" control={
                    <Checkbox
                        checked={showPassword}
                        onChange={e => setShowPassword(e.target.checked)}
                        value="primary"
                        inputProps={{ 'aria-label': 'primary checkbox' }}/>
                }/>
                <Button className={classes.button}
                    variant='contained' color='primary' startIcon={<InputIcon />} onClick={e=>props.login(email, password)}>ログイン</Button>
            </form>
        </Box>
    );
}

export default LoginForm;

そしてこれを使ってログインページのテンプレートを作ります。

/**
 * src/components/templates/LoginPageTemplate.jsx
 * @file ログインページのテンプレートコンポーネント
 */
import React, { Fragment } from 'react';
import { makeStyles } from '@material-ui/core/styles';

import Footer from '../organisms/Footer';
import LoginForm from '../organisms/LoginForm';

const useStyles = makeStyles(theme => ({
    content: {
        display: 'flex',
        justifyContent: 'center',
        marginTop: theme.spacing(4),
    }
}));

const LoginPageTemplate = props => {
    const classes = useStyles();
    return (
        <Fragment>
            <div className={classes.content}>
                <LoginForm login={props.login}/>
            </div>
            <Footer />
        </Fragment>
    );
}

export default LoginPageTemplate;

このテンプレートに対して、実際にログインを行う関数をpropsで渡す事で、frontend/src/components/pages/LoginPage.jsxにページを作ります。

/**
 * src/components/pages/LoginPage.jsx
 * @file ログインページ
 */

import React from 'react';
import { axios } from '../../App';

import { useSelector, useDispatch } from 'react-redux';

import { setToken } from '../../modules/UserModule';

import LoginPageTemplate from '../templates/LoginPageTemplate';

const LoginPage = () => {
    const dispatch = useDispatch();
    const token = useSelector(state => state.user.token);

    const login = (email, password) => {
        axios
            .post('/auth/login/', {
                email: email,
                password: password,
            })
            .then(res=>{dispatch(setToken(res.data.token));axios.defaults.headers.common['Authorization'] = 'JWT ' + token;})
            .catch(err=>{console.log(err);});
    }
    return (
        <LoginPageTemplate login={login}/>
    );
}

export default LoginPage;

これで、http://localhost:3000/loginにアクセスするとログイン画面が表示されたはずです。

実際にログイン出来たか確認する実装していないので、ここはブラウザのデバッグ機能でどういうResponseが返って来たのかを見ましょう。

ChromeではF12キーで開けて、メールアドレスとパスワードに関わらずログインを押すと、Networkのタブに何かしらの結果が出て来るはずです。

ステータスコード200で、tokenがJSON形式で返って来ていれば完了です。

認証トークンをヘッダに付与する

JSON Web Tokenでの認証は、HTTPヘッダにAuthorizationとして受け取ったトークンを設定する事で実現出来ます。

具体的には以下のようなヘッダを付与します。

{
  "header": {
    "Content-Type": "application/json"
    "Authorization": "JWT TTTTOOOOKKKKEEEENNNN"
  },
  "data": {},
}

上記のコードはJWTを取得したと同時にAxiosのヘッダにAuthorizationとして付与するようにしているので、既に認証状態で通信が可能です。

具体的には、axios.defaults.headers.common['Authorization'] = 'JWT ' + token;でデフォルト設定を行っています。

まとめ

とりあえずJWTでのトークンの受け取りと認証が出来るようになりました。

現在はバックエンド側の権限設定を適当にやっているので、Django REST Frameworkについて理解を深めて適切に設定したいです。

参考

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です