はじめに
ReactとDjangoでログイン機能の実装について勉強するために、ReactとDjangoでシンプルな画像付きブログアプリを作成するで作成したアプリケーションにログイン機能を追加しました。
その手順をまとめます。
概要
ログイン、ユーザー作成、ログイン中か確認、ログインして発行したトークンの期限の確認、投稿記事をユーザーと紐づける機能を実装します。
コンテナ変更
・JWTを追加します。
djangorestframework-jwt==1.11.0 #追加
・イメージを再作成します。
$ docker-compose build --no-cache api
API変更
・JWTを使って、API認証機能を追加します。
import datetime #追加
# バリデートメッセージを表示するため、言語設定を変更
#LANGUAGE_CODE = 'en-us
LANGUAGE_CODE = 'ja'
# 認証設定を追加
REST_FRAMEWORK = {
'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.IsAuthenticated',
),
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_jwt.authentication.JSONWebTokenAuthentication',
'rest_framework.authentication.SessionAuthentication',
'rest_framework.authentication.BasicAuthentication',
),
}
JWT_AUTH = {
'JWT_SECRET_KEY': SECRET_KEY,
'JWT_ALGORITHM': 'HS256',
'JWT_ALLOW_REFRESH': True,
'JWT_EXPIRATION_DELTA': datetime.timedelta(days=1), # リフレッシュした際のトークン期限
'JWT_REFRESH_EXPIRATION_DELTA': datetime.timedelta(days=28), # リフレッシュしても切れる最大のトークン期限
}
from rest_framework_jwt.views import obtain_jwt_token, verify_jwt_token, refresh_jwt_token #追加
urlpatterns = [
path('admin/', admin.site.urls),
path('api/token/', obtain_jwt_token), #追加
path('api/token/verify/', verify_jwt_token), #追加
path('api/token/refresh/', refresh_jwt_token), #追加
re_path(r'^api/', include('articles.urls')),
]
・記事にユーザーを紐づけるため、モデルを変更します。
import uuid
from django.db import models
from django.contrib.auth.models import User #追加
class Article(models.Model):
uuid = models.UUIDField('uuid', primary_key=True, default=uuid.uuid4, editable=False)
title = models.CharField(verbose_name="タイトル", max_length=100)
body = models.TextField('本文')
image = models.ImageField(upload_to='post_images', null=True)
user = models.ForeignKey(User, on_delete=models.CASCADE) #追加
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
・ユーザー作成、記事とユーザーの紐付け機能追加
from rest_framework import serializers
from rest_framework_jwt.settings import api_settings #追加
from .models import Article
from django.contrib.auth.models import User #追加
# 追加
class UserSerializer(serializers.ModelSerializer):
token = serializers.SerializerMethodField()
password = serializers.CharField(write_only=True)
def get_token(self, obj):
jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER
jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER
payload = jwt_payload_handler(obj)
token = jwt_encode_handler(payload)
return token
def create(self, validated_data):
password = validated_data.pop('password', None)
instance = self.Meta.model(**validated_data)
if password is not None:
instance.set_password(password)
instance.save()
return instance
class Meta:
model = User
fields = ('token', 'username', 'password')
class ArticleSerializer(serializers.ModelSerializer):
# 記事取得する際、ユーザー情報を取得する
user = UserSerializer() #追加
class Meta:
model = Article
fields = '__all__'
read_only_fields = ('created_at', 'updated_at')
from rest_framework import viewsets
from .models import Article
from .serializers import ArticleSerializer, UserSerializer #追加
from rest_framework.response import Response
from rest_framework import permissions, status #追加
from rest_framework_jwt.authentication import JSONWebTokenAuthentication #追加
class ListArticle(viewsets.ModelViewSet):
queryset = Article.objects.all()
serializer_class = ArticleSerializer
# 記事一覧取得
def list(self, request):
data = ArticleSerializer(Article.objects.all().select_related('user').order_by('created_at').reverse(), many=True).data #変更
return Response(status=200, data=data)
# 記事詳細取得
def retrieve(self, request, pk=None):
article_id = pk
data = ArticleSerializer(Article.objects.filter(uuid=article_id), many=True).data
return Response(status=200, data=data)
#追加
def create(self, request):
image=''
if('image' in request.data):
image = request.data['image']
article = Article.objects.create(title=request.data['title'], body=request.data['body'], image=image, user=request.user)
serializer = ArticleSerializer(article, many=False)
response = {'message': 'Article created' , 'result': serializer.data}
return Response(response, status=200)
#追加
class UserList(viewsets.ModelViewSet):
permission_classes = (permissions.AllowAny,)
def create(self, request):
serializer = UserSerializer(data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
モデルを変更したので、DBマイグレートします。
$ docker-compose run --rm api python blog/manage.py makemigrations
2つの質問されますが、どちらも「1」を入力してください。
$ docker-compose run --rm api python blog/manage.py migrate
以上で、API側の実装は完了です。
フロントエンド変更
クッキーを使用するので、以下をインストールします。
$ docker-compose run --rm front npm --prefix ./blog install react-cookie
・ログイン、ユーザー登録ページの作成
import React, {useState} from 'react';
import { withCookies } from 'react-cookie';
import axios from 'axios';
import { Button } from '@material-ui/core';
/**
* @author
* @function Auth
**/
const Auth = (props) => {
const [ username, setUsername ] = useState("");
const [ password, setPassword ] = useState("");
const [ loginFunction, setLoginFunction ] = useState(true);
const [ errorMessage, setError ] = useState("");
const auth = (event) => {
event.preventDefault();
let form_data = new FormData();
form_data.append('username', username);
form_data.append('password', password);
const postUri = loginFunction ? `${process.env.REACT_APP_END_POINT}/api/token/` : `${process.env.REACT_APP_END_POINT}/api/users/`;
axios.post(postUri, form_data, {
headers: {
'Content-Type': 'application/json'
},
})
.then( res => {
props.cookies.set('blog-token', res.data.token);
window.location.href = "/";
})
.catch( error => {
setError(error.response.data)
});
}
const changeFunction = () => {
setLoginFunction(!loginFunction)
setUsername("")
setPassword("")
setError("")
}
return(
<div className="form central-placement">
<form onSubmit={auth}>
<div>
<h3>{loginFunction ? 'ログイン' : 'サインイン'}</h3>
</div>
{ errorMessage.non_field_errors ? <p className="red">{errorMessage.non_field_errors}</p> : null }
<div className="form-element">
<input type="text" name="username" value={username}
className="form-element--username"
onChange={(e) => setUsername(e.target.value)}
placeholder="ユーザー名" />
{ errorMessage.username ? <p className="red">{errorMessage.username}</p> : null }
</div>
<div className="form-element">
<input type="password" name="password" value={password}
className="form-element--password"
onChange={(e) => setPassword(e.target.value)}
placeholder="パスワード" />
{ errorMessage.password ? <p className="red">{errorMessage.password}</p> : null }
</div>
<div className="form-element right-placement">
<Button variant="contained" color="primary" type="submit" >{loginFunction ? 'ログイン' : 'サインイン'} </Button>
</div>
<div className="center-placement">
<Button variant="contained" type="button" onClick={changeFunction}>{loginFunction ? 'アカウントを作成する' : 'ログインする'} </Button>
</div>
</form>
</div>
)
}
export default withCookies(Auth)
・ログインしていない場合、トークンの期限が切れている場合は、ログイン画面へ遷移する処理追加。
import { withCookies } from 'react-cookie';
const LoggedOut = props =>
props.cookies.get('blog-token') ? null : props.children;
export default withCookies(LoggedOut);
import React from 'react';
import axios from 'axios'
import { Redirect } from 'react-router-dom';
import { withCookies } from 'react-cookie';
const LoggedIn = (props) => {
// tokenが有効であるか確認
if (props.cookies.get('blog-token')) {
let form_data = new FormData();
form_data.append('token', props.cookies.get('blog-token'));
axios.post(`${process.env.REACT_APP_END_POINT}/api/token/verify/`, form_data,{
headers: {
'content-type': 'multipart/form-data'
},
})
.catch( error => {
alert('再度ログインを行なってください');
props.cookies.remove('blog-token')
window.location.href = "/auth";
});
return props.children;
} else {
return <Redirect to={'/auth'} />
}
}
export default withCookies(LoggedIn);
・ヘッダーの表示、ログアウトボタン追加
import React from 'react'
import AppBar from '@material-ui/core/AppBar';
import Toolbar from '@material-ui/core/Toolbar';
import { Typography, Button } from '@material-ui/core';
import { Link } from 'react-router-dom';
import { withCookies } from 'react-cookie'; /** 追加 **/
/**
* @author
* @function Header
**/
const Header = (props) => {
/** 追加 **/
const token = props.cookies.get('blog-token');
const logout = () => {
props.cookies.remove('blog-token');
window.location.href = "/auth";
}
return(
<AppBar position="static">
<Toolbar>
{ /** 変更 **/ }
<Link to={token ? "/" : "/auth"}>
<Typography variant="h4" type="title" color="inherit" >
Blog
</Typography>
</Link>
{token ?
<div style={{marginLeft: 'auto'}}>
<Link to="/new">
<Button color="inherit">新規作成</Button>
</Link>
<Button onClick={logout}>ログアウト</Button>
</div>
: null}
</Toolbar>
</AppBar>
)
}
export default withCookies(Header) /** 変更 **/
・上記で作成したファイルのインポート、子コンポーネントでクッキーが使用できるよう、App.jsを変更します。
import React from 'react';
import Auth from './components/Auth'; /** 追加 **/
import Home from './components/Home';
import Post from './components/Post';
import Header from './components/Header';
import LoggedIn from './components/LoggedIn'; /** 追加 **/
import LoggedOut from './components/LoggedOut'; /** 追加 **/
import { BrowserRouter as Router, Route } from 'react-router-dom';
import registerForm from './components/registerForm';
import { CookiesProvider, withCookies } from 'react-cookie';
function App() {
return (
<Router>
<div className="App">
{ /** 変更 **/ }
<CookiesProvider>
<Header />
<LoggedIn>
<Route path="/" exact component={Home} />
<Route path="/post/:postId/" component={Post} />
<Route path="/new" component={registerForm} />
</LoggedIn>
<LoggedOut>
<Route path="/auth" component={Auth} />
</LoggedOut>
</CookiesProvider>
</div>
</Router>
);
}
export default withCookies(App); /** 変更 **/
・記事取得、登録、削除はトーク認証が必要なため、変更する。
import React, {useState, useEffect} from 'react';
import axios from 'axios';
import Card from '@material-ui/core/Card';
import CardMedia from '@material-ui/core/CardMedia';
import CardContent from '@material-ui/core/CardContent';
import { Typography } from '@material-ui/core';
import { NavLink } from 'react-router-dom';
import convertFormat from '../function/Convert.js';
import { withCookies } from 'react-cookie'; /** 追加 **/
/**
* @author
* @function Home
**/
const Home = (props) => {
const [articleList, setArticle] = useState([]);
/** 追加 **/
useEffect(() => {
axios
.get(`${process.env.REACT_APP_END_POINT}/api/articles/`, {
headers: {
'Authorization': `JWT ${props.cookies.get('blog-token')}`
}
})
.then(res => {
const articleList = res.data;
setArticle(articleList);
})
.catch(err => {
console.log(err);
});
}, [props.cookies]);
return(
<div>
{
articleList.map(article => {
return (
<Card
key={`ranking-item-${article.uuid }`}
style={{ maxWidth: '500px', margin: '32px auto'}}
src={`post/${article.uuid}`}
>
<NavLink key={article.uuid} to={`/post/${article.uuid}`}>
<CardMedia
image={article.image ? process.env.REACT_APP_END_POINT+article.image: 'http://design-ec.com/d/e_others_50/l_e_others_500.png'}
title={article.title}
style={{ height: '200px' }}
/>
<CardContent>
<Typography type="title" variant="h4">
{article.title}
</Typography>
{ /** 追加 **/ }
<Typography className='right-placement' type="title" >
by {article.user.username}
</Typography>
<Typography className='right-placement' type="title" >
{convertFormat(article.created_at)}
</Typography>
</CardContent>
</NavLink>
</Card>
)
})
}
</div>
)
}
export default withCookies(Home) /** 変更 **/
import React from 'react';
import axios from 'axios';
import { Button } from '@material-ui/core';
import { withCookies } from 'react-cookie'; /** 追加 **/
class registerForm extends React.Component{
constructor(props) {
super(props);
this.state = {
title: '',
body: '',
image: null,
};
this.titleChange = this.titleChange.bind(this);
this.bodyChange = this.bodyChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
addArticle(title, body, image) {
let form_data = new FormData();
if(image){
form_data.append('image', image, image.name);
}
form_data.append('title', title);
form_data.append('body', body);
/** 変更 **/
axios.post(`${process.env.REACT_APP_END_POINT}/api/articles/`, form_data,{headers: {
'content-type': 'multipart/form-data',
'Authorization': `JWT ${this.props.cookies.get('blog-token')}`
}
}).then(res => {
window.location.href="/";
}).catch(err => {
console.log(err);
});
}
titleChange(event) {
this.setState({
title: event.target.value,
});
}
bodyChange(event) {
this.setState({
body: event.target.value,
});
}
imageChange = (e) => {
this.setState({
image: e.target.files[0],
})
};
handleSubmit(event) {
event.preventDefault();
if(this.state.title === ''){
return;
}
if(this.state.body === ''){
return;
}
this.setState({
title: '',
body: '',
image: ''
});
this.addArticle(this.state.title, this.state.body, this.state.image);
}
render() {
return (
<div className="form central-placement">
<form onSubmit={this.handleSubmit}>
<div className="form-element">
<input type="text" value={this.state.title} onChange={this.titleChange} className="form-element--title" placeholder="タイトル" />
</div>
<div className="form-element">
<textarea type="text" value={this.state.body} onChange={this.bodyChange} className="form-element--body" placeholder="本文" />
</div>
<div className="form-element">
<input type="file"
accept="image/png, image/jpeg" onChange={this.imageChange} />
</div>
<div className="right-placement">
<Button variant="contained" color="primary" type="submit">新規作成</Button>
</div>
</form>
</div>
);
}
}
export default withCookies(registerForm) /** 変更 **/
import React, {useState, useEffect} from 'react';
import axios from 'axios';
import Card from '@material-ui/core/Card';
import CardMedia from '@material-ui/core/CardMedia';
import CardContent from '@material-ui/core/CardContent';
import { Typography, Button } from '@material-ui/core';
import DeleteIcon from '@material-ui/icons/Delete';
import convertFormat from '../function/Convert.js'; /** 追加 **/
import { withCookies } from 'react-cookie'; /** 追加 **/
/**
* @author
* @function Post
**/
const Post = (props) => {
const [articleList, setArticle] = useState('');
useEffect(() => {
axios
.get(`${process.env.REACT_APP_END_POINT}/api/articles/${props.match.params.postId}/`,
{
headers: {
'Authorization': `JWT ${props.cookies.get('blog-token')}`
}
}
)
.then(res => {
const articleList = res.data[0];
setArticle(articleList);
})
.catch(err => {
console.log(err);
});
}, [props.match.params.postId, props.cookies]);
const deleteArticle = () => {
if(window.confirm('この記事を削除してよろしいですか?')) {
axios.delete(`${process.env.REACT_APP_END_POINT}/api/articles/${articleList.uuid}/`,
{
headers: {
'Authorization': `JWT ${props.cookies.get('blog-token')}`
}
}
)
.then(res => {
window.location.href="/";
}).catch(err => {
console.log(err);
});
}
}
return(
<div>
{(() => {
if (articleList) {
return(
<Card
key={`ranking-item-${articleList.uuid }`}
style={{ maxWidth: '1000px', margin: '32px auto'}}
src={`post/${articleList.uuid}`}
>
{ articleList.image &&
<CardMedia
image={process.env.REACT_APP_END_POINT+articleList.image}
title={articleList.title}
style={{ height: '400px' }}
/>
}
<CardContent style={{ textAlign: 'left' }}>
<Typography variant="h3" type="title" style={{margin:'0 0 20px 0'}}>
{articleList.title}
</Typography>
<Typography className='right-placement' type="title" >
by {articleList.user.username}
</Typography>
<Typography className='right-placement' type="title" >
{convertFormat(articleList.created_at)}
</Typography>
<Typography variant="h5" type="title" style={{margin:'0 0 20px 0'}}>
{articleList.body}
</Typography>
<div className="right-placement">
<Button onClick={deleteArticle} variant="contained" color="secondary" size="small">
<DeleteIcon />
削除
</Button>
</div>
</CardContent>
</Card>
)
}
})()}
</div>
)
}
export default withCookies(Post)
・最後にログイン画面のスタイルを変更します。
// 追加
.center-placement {
display: flex;
justify-content: center;
}
.form-element--title,
.form-element--username,
.form-element--password {
width: 100%;
height: 25px;
}
.red {
color: crimson;
}
おわりに
ReactもDjangoも勉強始めたばかりなので、適切でない書き方であったり、ログイン中のユーザーが投稿していない記事を削除できてしまったりと、不完全な状態ではありますが、少しでもお役に立てたら幸いです。
参考文献
・https://www.udemy.com/course/react-django-full-stack/
・https://medium.com/swlh/django-rest-framework-with-react-jwt-authentication-part-1-a24b24fa83cd