ReactとDjangoでシンプルな画像付きブログアプリを作成する2 - ログイン機能を追加

## はじめに
ReactとDjangoでログイン機能の実装について勉強するために、[ReactとDjangoでシンプルな画像付きブログアプリを作成する](https://qiita.com/Tomoyuki_Y/items/b6990ea3e6fff228b60b)で作成したアプリケーションにログイン機能を追加しました。
その手順をまとめます。

![login.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/355306/ecc7ab9e-ee82-aeed-63b7-c6c491ca7f56.gif)


## 概要
ログイン、ユーザー作成、ログイン中か確認、ログインして発行したトークンの期限の確認、投稿記事をユーザーと紐づける機能を実装します。

## コンテナ変更

・JWTを追加します。

```requirements.txt
djangorestframework-jwt==1.11.0 #追加
```

・イメージを再作成します。

```
$ docker-compose build --no-cache api
```

## API変更

・JWTを使って、API認証機能を追加します。

```python:setting.py

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), # リフレッシュしても切れる最大のトークン期限
}

```

```python:blog/urls.py
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')),
]
```

・記事にユーザーを紐づけるため、モデルを変更します。

```python:articles/models.py
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)
```

・ユーザー作成、記事とユーザーの紐付け機能追加

```python:articles/searilisers.py
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')

```

```python:articles/views.py
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
```

・ログイン、ユーザー登録ページの作成

```jsx:components/Auth.js
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)
```

・ログインしていない場合、トークンの期限が切れている場合は、ログイン画面へ遷移する処理追加。

```jsx:components/LoggedOut.js
import { withCookies } from 'react-cookie';

const LoggedOut = props =>
  props.cookies.get('blog-token') ? null : props.children;

export default withCookies(LoggedOut);
```

```jsx:components/LoggedIn.js
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);
```

・ヘッダーの表示、ログアウトボタン追加

```jsx:components/Header.js
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を変更します。

```jsx: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); /** 変更 **/
```

・記事取得、登録、削除はトーク認証が必要なため、変更する。

```jsx:components/Home.js
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) /** 変更 **/
```

```jsx:components/registerForm.js
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) /** 変更 **/
```

```jsx:components/Post.js
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)

```

・最後にログイン画面のスタイルを変更します。

```css:index.css
// 追加
.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