今回は、ログイン・ログアウト等の部分を作ります。
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について理解を深めて適切に設定したいです。