Django REST frameworkとReactでJWT認証

## はじめに

個人開発で、DRFとReactで認証処理を作成しました。
普段は日記がてら投稿しているのですが、ある程度まとめたものを投稿しようと思います。

### 認証関連の記事

* [個人開発記録　2024/05/07](https://qiita.com/kare-pan/items/21aa787c6ee0593e287d)
* [個人開発記録　2024/05/06(djangoでログイン機能を実装)](https://qiita.com/kare-pan/items/7a910401dc0035761813)
* [個人開発投稿2週間分の振り返り](https://qiita.com/kare-pan/items/e3367c91f1037987236b)


## インストール

dj-rest-authとdjangorestframework-simplejwtをインストール。
dj-rest-authは簡単に認証処理をカスタマイズできるライブラリで、
djangorestframework-simplejwtは、簡単にJWT認証を実装できるライブラリです。

```bash
pip install dj-rest-auth djangorestframework-simplejwt
```

## settings.pyを修正

```diff:settings.py
INSTALLED_APPS = [
    ...
+    'rest_framework.authtoken',
]

+ AUTH_USER_MODEL = 'user.User'

REST_FRAMEWORK = {
+    'DEFAULT_AUTHENTICATION_CLASSES': (
+        'rest_framework_simplejwt.authentication.JWTAuthentication',
+    ),
    'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
    'PAGE_SIZE': 10
}

+ # dj-rest-authでJWT認証をする設定
+ REST_AUTH = {
+     "USE_JWT": True,
+ }

+ # JWTの設定
+ # 以下ではJWTの期限を5分に設定している
+ SIMPLE_JWT = {
+     'ACCESS_TOKEN_LIFETIME': timedelta(minutes=5),
+ }
```

以下分については、次に作成する`User`モデルを指定しています。

```python
AUTH_USER_MODEL = 'user.User'
```

## modelを作成

```python:models.py
from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager
from django.db import models
from django.contrib.auth.models import PermissionsMixin


class UserManager(BaseUserManager):
    def create_user(self, email, password=None, **extra_fields):
        if not email:
            raise ValueError("The Email field must be set")
        email = self.normalize_email(email)
        user = self.model(email=email, **extra_fields)
        user.set_password(password)
        user.save(using=self._db)
        return user

    def create_superuser(self, email, password=None, **extra_fields):
        extra_fields.setdefault("is_staff", True)
        extra_fields.setdefault("is_superuser", True)

        return self.create_user(email, password, **extra_fields)


class User(AbstractBaseUser, PermissionsMixin):
    email = models.EmailField(unique=True)
    first_name = models.CharField(max_length=30, blank=True)
    last_name = models.CharField(max_length=30, blank=True)
    bio = models.CharField(max_length=280, blank=True)
    location = models.CharField(max_length=100, blank=True)
    birth_date = models.DateField(null=True, blank=True)
    is_active = models.BooleanField(default=True)
    is_staff = models.BooleanField(default=False)

    objects = UserManager()

    USERNAME_FIELD = 'email'
    # superuserを作成する際にemail以外で追加で必須にする項目を定義
    REQUIRED_FIELDS = []

    def __str__(self):
        return self.email

```

`User`はメールアドレス、パスワードで認証したいため、`AbstractBaseUser`をします。

## serializerを作成

```python:serializer.py
from dj_rest_auth.serializers import LoginSerializer
from django.contrib.auth import authenticate
from rest_framework import serializers

from .models import User


class UserSerializer(serializers.ModelSerializer):
    password = serializers.CharField(write_only=True, required=False)

    class Meta:
        model = User
        fields = ('email', 'password', 'first_name', 'last_name', 'bio', 'location', 'birth_date')

    def create(self, validated_data):
        # パスワードを validated_data から削除し、ユーザー作成後に設定する
        password = validated_data.pop('password', None)
        user = User.objects.create_user(**validated_data)
        if password is not None:
            user.set_password(password)
            user.save()
        return user


class CustomLoginSerializer(LoginSerializer):
    username = None
    email = serializers.EmailField(required=True)

    def get_auth_user(self, username, email, password):
        user = authenticate(username=email, password=password)
        if not user:
            raise serializers.ValidationError("Invalid email or password")
        return user

```

### UserSerializerについて

UserSerializerは主に`User`をCURDする際に使います。

### CustomLoginSerializerについて

こちらはdj_rest_authで用意されている、`LoginSerializer`を継承しています。
今回はdjangoのデフォルトの`username`ではなく、`email`で認証したいため、カスタマイズしています。
dj_rest_authでならちょっと書くだけで、簡単にカスタマイズできます。

## viewsを作成

dj_rest_authの`LoginView`を使いたいため、ログインはViewSetを使用しません。
dj_rest_authの`LoginView`は中身を見るとよりわかるのですが、認証関連をいろいろやってくれています。
ViewSetでは`User`作成後にアクセストークンを返すようにしたいため、`create`メソッドをカスタマイズしています。

```python:views.py
from dj_rest_auth.views import LoginView
from rest_framework import viewsets
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework import status
from rest_framework_simplejwt.tokens import RefreshToken

from .serializer import UserSerializer, CustomLoginSerializer
from .models import User


# ユーザのCRUD操作を行うViewSet
class UserModelViewSet(viewsets.ModelViewSet):
    queryset = User.objects.all()
    serializer_class = UserSerializer

    def get_permissions(self):
        if self.action == "create":
            permission_classes = [AllowAny]
        else:
            permission_classes = [IsAuthenticated]
        return [permission() for permission in permission_classes]

    def create(self, request, *args, **kwargs):
        serializer = self.get_serializer(data=request.data)
        if serializer.is_valid():
            user = serializer.save()  # userインスタンスを保存して取得
            refresh = RefreshToken.for_user(user)  # 新規ユーザーのためのトークン生成
            response_data = {
                'user': serializer.data,
                'refresh': '',  # refreshは使用しない
                'access': str(refresh.access_token),
            }
            return Response(response_data, status=status.HTTP_201_CREATED)
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)


class CustomLoginView(LoginView):
    serializer_class = CustomLoginSerializer

```

## React側を作成

ここでは全体は書かずにJWTにかかわる部分のみを書きます。

### ユーザー登録画面を作成

ユーザー登録後にアクセストークンをローカルストレージに保存します。

```js:RegisterForm.jsx
const registerUser = (data) => {
    const jsonData = JSON.stringify(data);
    const config = {
        headers: {
            'Content-Type': 'application/json'
        }
    };
    const url = `${API_URL}/api/users/`;
    return axios.post(url, jsonData, config)
};

export const RegisterForm = () => {
  const handleSubmit = (event) => {
    event.preventDefault();
    const data = new FormData(event.currentTarget);

    const registrationData = {
      email: data.get('email'),
      password: data.get('password'),
      first_name: data.get('firstName'),
      last_name: data.get('lastName')
    };

    registerUser(registrationData)
      .then((response) => {
        if (response.status === 201) {
            localStorage.setItem('access_token', response.data.access);
            localStorage.setItem('user', JSON.stringify(response.data.user));
        }
      });
  };

```

### ログイン画面を作成

こちらもログイン完了後にアクセストークンをローカルストレージに保存します。

```js:LoginForm.jsx
const login = (data) => {
    const jsonData = JSON.stringify(data);
    const config = {
        headers: {
            'Content-Type': 'application/json',
        }
    };
    const url = `${API_URL}/api/user/login/`;
    return axios.post(url, jsonData, config)
};

export const LoginForm = () => {
  const handleSubmit = (event) => {
    event.preventDefault();
    const data = new FormData(event.currentTarget);

    const loginData = {
      email: data.get('email'),
      password: data.get('password'),
    };

    login(loginData)
      .then((response) => {
        if (response.status === 200) {
            localStorage.setItem('access_token', response.data.access);
            localStorage.setItem('user', JSON.stringify(response.data.user));
        }
      });
  };
```

### ユーザー情報更新画面（ログインが必要な画面）を作成

`getUser`ではHeaderに`Authorization`を設定します。
Djangoではこの`Authorization`をもとに認証します。

```js:UpdateForm.jsx
const getUser = (data) => {
    const config = {
        headers: {
            'Content-Type': 'application/json',
            'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
        }
    };
    const userJson = JSON.parse(localStorage.getItem('user'));
    const url = `${API_URL}/api/users/${userJson.pk}`;
    return axios.get(url, config)
};

export const UpdateForm = () => {
  const [formData, setFormData] = useState({
    firstName: '',
    lastName: '',
    email: '',
    password: '',
    bio: '',
    location: '',
    birthYear: '',
    birthMonth: '',
    birthDay: '',
  });

  // ユーザーデータを取得してフォームに設定
  useEffect(() => {
    const loadUserData = async () => {
      try {
        const response = await getUser(); // ユーザー情報取得API
        const birthDate = new Date(response.data.birth_date);
        setFormData({
          ...formData,
          firstName: response.data.first_name,
          lastName: response.data.last_name,
          email: response.data.email,
          password: '', // パスワードは通常非表示
          bio: response.data.bio,
          location: response.data.location,
          birthYear: birthDate.getFullYear().toString(),
          birthMonth: (birthDate.getMonth() + 1).toString(), // JavaScriptの月は0から始まるため+1
          birthDay: birthDate.getDate().toString(),
        });
      } catch (error) {
        console.error('Failed to fetch user data:', error);
      }
    };
    loadUserData();
  }, []);
```