※当サイトではアフィリエイト広告を利用しています。

django

DjangoとReactでCookieを用いたJWT認証システムの構築

Webアプリケーション開発において、セキュアな認証システムの構築は欠かせない要素です。特に、フロントエンドとバックエンドが異なるフレームワークで構築されている場合、認証情報の管理方法が重要な課題となります。この記事では、フロントエンドにReactを、バックエンドにDjangoを使用し、JSON Web Tokens(JWT)をCookieに保存して認証を行う方法について紹介します。

想定読者

  • DjangoとReactの基礎知識がある人

実行環境

  • wsl2 ubuntu22.04
  • python3==3.9.7
  • Django==4.2.9
  • djoser==2.2.2
  • djangorestframework-simplejwt==5.3.1
  • django-cors-headers==4.0.0
  • npm==9.5.1
  • node==v18.16.0

アプリイメージ

サインインすることでcookieにaccessTokenとrefreshTokenが保存されます。サインアウトすることでそれぞれのTokenを削除します。

cookie_save

バックエンド

DjangoとDjango REST Frameworkを基盤として構築し、JWTによる認証システムとして実装します。

Python仮想環境の作成

まず、プロジェクト専用のPython仮想環境を作成します。これにより、プロジェクトの依存関係をグローバルなPython環境から分離できます。

python -m venv .venv

 

仮想環境のアクティベーション

次に、作成した仮想環境をアクティベートします。これにより、以降のコマンドは仮想環境内で実行されるようになります。

source .venv/bin/activate

 

ライブラリのインストール

必要なライブラリとその特定のバージョンをインストールします。Djoserをインストールする際、Django REST Frameworkとdjangorestframework-simplejwtが依存関係として自動的にインストールされるため、これらは個別にインストールする必要はありません。

pip install django==4.2.9
pip install djoser==2.2.2
pip install django-cors-headers==4.0.0

 

Djangoプロジェクトの作成

最初に、configという名前でDjangoプロジェクトを作成します。作成したプロジェクトディレクトリ(config)をbackendにリネームします。このようにする理由はsettings.pyなどをconfigディレクトリに格納するためです。(この辺は好みかもしれません)

django-admin startproject config
mv config backend

 

コマンド実行後のディレクトリ構造は以下の通りです。

backend/
├── config
│   ├── __init__.py
│   ├── asgi.py
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
└── manage.py

 

appの作成

以下のコマンドで2種類のアプリを作成します。

python manage.py startapp auth
python manage.py startapp users

 

users/managers.py

usersフォルダに新規でmanagers.pyを作成します。

from django.contrib.auth.base_user import BaseUserManager


class CustomUserManager(BaseUserManager):
    """
    Custom User Model Manager where email is the unique identifier for authentication.
    """

    def create_user(self, email, password, first_name, last_name, **extra_fields):
        """
        Create and save a user with the given email address, date of birth, country and password.
        """
        if not email:
            raise ValueError(_('The Email must be set'))
        email = self.normalize_email(email)
        user = self.model(email=email, first_name=first_name, last_name=last_name, **extra_fields)
        user.set_password(password)
        user.save()
        return user

    def create_superuser(self, email, password, first_name, last_name, **extra_fields):
        """
        Create and save a superuser with the given email address and password.
        """
        extra_fields.setdefault('is_staff', True)
        extra_fields.setdefault('is_superuser', True)
        extra_fields.setdefault('is_active', True)

        return self.create_user(email, password, first_name=first_name,
                                last_name=last_name, **extra_fields)

users/models.py

usersフォルダのmodels.pyにcustomuserモデルを追記します。

from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin
from django.db import models

from .managers import CustomUserManager


# Create your models here.
class CustomUser(AbstractBaseUser, PermissionsMixin):

    class Meta:
        verbose_name = 'User'
        verbose_name_plural = 'Users'

    email = models.EmailField(max_length=255, unique=True)
    first_name = models.CharField(max_length=100)
    last_name = models.CharField(max_length=100)

    USERNAME_FIELD = 'email'
    REQUIRED_FIELDS = ('first_name', 'last_name',)

    is_staff = models.BooleanField(
        default=False,

    )
    is_active = models.BooleanField(
        default=True,
    )

    objects = CustomUserManager()

    @property
    def get_full_name(self):
        return f'{self.first_name} {self.last_name}'

    def __str__(self) -> str:
        return self.email

 

auth/authenticate.py

authフォルダに新規でauthenticate.pyを作成します。これはdjangorestframework-simplejwtのJWTAuthenticationクラスをカスタマイズしたCustomJWTAuthenticationクラスを定義しています。このカスタマイズは、JWT(JSON Web Token)を用いた認証プロセスにおいて、リクエストヘッダーだけでなく、Cookieからもトークンを取得する機能になっています。トークンが存在しないか、検証に失敗した場合はNoneを返し、そのリクエストは認証しないようにしています。

from django.conf import settings
from django.middleware.csrf import CsrfViewMiddleware
from rest_framework import exceptions
from rest_framework_simplejwt.authentication import JWTAuthentication


class CustomJWTAuthentication(JWTAuthentication):
    """Custom authentication class"""

    def authenticate(self, request):
        header = self.get_header(request)
        if header is None:
            access_token = request.COOKIES.get(settings.SIMPLE_JWT['AUTH_COOKIE']) or None
        else:
            access_token = self.get_raw_token(header)
        if access_token is None:
            return None

        validated_token = self.get_validated_token(access_token)
        return self.get_user(validated_token), validated_token

 

auth/views.py

authフォルダのviews.pyに各種Viewを追記します。
  • LoginView
    •  TokenObtainPairViewを継承しており、ユーザーがログインする際にアクセストークンとリフレッシュトークンを発行します。このビューはPOSTリクエストを受け取り、有効な認証情報が提供された場合、レスポンスにアクセストークンとリフレッシュトークンを含めます。さらに、これらのトークンをCookieに設定してクライアントに送り返します。Cookieの設定には、ドメイン、パス、有効期限、セキュアフラグ、HttpOnlyフラグ、SameSiteフラグなどのパラメータが含まれます。
  • LogoutView
    •  ユーザーがログアウトする際にアクセストークンとリフレッシュトークンのCookieを削除するために使用されます。これにより、クライアント側でこれらのトークンが無効になります。このビューは、APIViewを継承しており、AllowAnyパーミッションクラスを使用して、認証されていないユーザーもアクセスできるようにしています。
  • TokenVerifyView
    • 提供されたアクセストークンが有効であるかどうかを検証するために使用します。このビューは、クライアントから送信されたCookie内のアクセストークンを取得し、そのトークンを検証します。トークンが有効である場合は、有効であることを示すレスポンスを返します。トークンが無効である場合は、エラーメッセージとともに400 Bad Requestエラーを返します。
  • RefreshTokenView
    • リフレッシュトークンを使用して新しいアクセストークンとリフレッシュトークンを発行するために使用します。クライアントから送信されたリフレッシュトークンを検証し、有効であれば新しいトークンを生成してレスポンスに含めます。また、新しいトークンをCookieに設定して返します。このプロセスにより、ユーザーはログイン状態を維持しつつ、トークンを定期的に更新することができます。
import datetime

from django.conf import settings
from rest_framework import status
from rest_framework.permissions import AllowAny
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
from rest_framework_simplejwt.serializers import TokenVerifySerializer
from rest_framework_simplejwt.tokens import RefreshToken
from rest_framework_simplejwt.views import TokenObtainPairView


class LoginView(TokenObtainPairView):
    def post(self, request: Request, *args, **kwargs) -> Response:
        response = super().post(request, *args, **kwargs)

        # access token set cookie
        access_token = response.data["access"]
        response.set_cookie(
            key=settings.SIMPLE_JWT["AUTH_COOKIE"],
            value=access_token,
            domain=settings.SIMPLE_JWT["AUTH_COOKIE_DOMAIN"],
            path=settings.SIMPLE_JWT["AUTH_COOKIE_PATH"],
            expires=datetime.datetime.utcnow() + settings.SIMPLE_JWT["ACCESS_TOKEN_LIFETIME"],
            secure=settings.SIMPLE_JWT["AUTH_COOKIE_SECURE"],
            httponly=settings.SIMPLE_JWT["AUTH_COOKIE_HTTP_ONLY"],
            samesite=settings.SIMPLE_JWT["AUTH_COOKIE_SAMESITE"],
        )

        # refresh token set cookie
        refresh_token = response.data["refresh"]
        response.set_cookie(
            key=settings.SIMPLE_JWT["AUTH_COOKIE_REFRESH"],
            value=refresh_token,
            domain=settings.SIMPLE_JWT["AUTH_COOKIE_DOMAIN"],
            path=settings.SIMPLE_JWT["AUTH_COOKIE_PATH"],
            expires=datetime.datetime.utcnow() + settings.SIMPLE_JWT["REFRESH_TOKEN_LIFETIME"],
            secure=settings.SIMPLE_JWT["AUTH_COOKIE_SECURE"],
            httponly=settings.SIMPLE_JWT["AUTH_COOKIE_HTTP_ONLY"],
            samesite=settings.SIMPLE_JWT["AUTH_COOKIE_SAMESITE"],
        )
        return response


class LogoutView(APIView):
    permission_classes = [AllowAny, ]

    def post(self, request):
        # アクセストークンのクッキーを削除
        response = Response({"detail": "Successfully logged out."}, status=status.HTTP_200_OK)
        response.delete_cookie(settings.SIMPLE_JWT['AUTH_COOKIE'], domain=settings.SIMPLE_JWT['AUTH_COOKIE_DOMAIN'], path=settings.SIMPLE_JWT['AUTH_COOKIE_PATH'])

        # リフレッシュトークンのクッキーを削除
        response.delete_cookie(settings.SIMPLE_JWT['AUTH_COOKIE_REFRESH'], domain=settings.SIMPLE_JWT['AUTH_COOKIE_DOMAIN'], path=settings.SIMPLE_JWT['AUTH_COOKIE_PATH'])

        return response


class TokenVerifyView(APIView):
    def post(self, request, *args, **kwargs):
        # Authorization ヘッダーからトークンを取得
        access_token = request.COOKIES.get(settings.SIMPLE_JWT['AUTH_COOKIE'])

        if access_token is None:
            return Response({"detail": "Authorization header is missing."}, status=status.HTTP_400_BAD_REQUEST)

        # トークンを検証
        serializer = TokenVerifySerializer(data={"token": access_token})
        try:
            serializer.is_valid(raise_exception=True)
        except TokenError as e:
            raise InvalidToken(e.args[0])

        return Response({"message": "Token is valid"}, status=status.HTTP_200_OK)


class RefreshTokenView(APIView):
    permission_classes = [AllowAny, ]

    def post(self, request, *args, **kwargs):
        # クッキーからリフレッシュトークンを取得
        refresh_token = request.COOKIES.get(settings.SIMPLE_JWT['AUTH_COOKIE_REFRESH'])

        if refresh_token is None:
            return Response({"detail": "Refresh token is missing."}, status=status.HTTP_400_BAD_REQUEST)

        try:
            # リフレッシュトークンを検証し、新しいアクセストークンとリフレッシュトークンを生成
            refresh = RefreshToken(refresh_token)
            data = {
                'access': str(refresh.access_token),
                'refresh': str(refresh)
            }

            # 新しいアクセストークンとリフレッシュトークンをクッキーに設定
            response = Response(data, status=status.HTTP_200_OK)

            # access token set cookie
            access_token = response.data["access"]
            response.set_cookie(
                key=settings.SIMPLE_JWT["AUTH_COOKIE"],
                value=access_token,
                domain=settings.SIMPLE_JWT["AUTH_COOKIE_DOMAIN"],
                path=settings.SIMPLE_JWT["AUTH_COOKIE_PATH"],
                expires=datetime.datetime.utcnow() + settings.SIMPLE_JWT["ACCESS_TOKEN_LIFETIME"],
                secure=settings.SIMPLE_JWT["AUTH_COOKIE_SECURE"],
                httponly=settings.SIMPLE_JWT["AUTH_COOKIE_HTTP_ONLY"],
                samesite=settings.SIMPLE_JWT["AUTH_COOKIE_SAMESITE"],
            )

            # refresh token set cookie
            refresh_token = response.data["refresh"]
            response.set_cookie(
                key=settings.SIMPLE_JWT["AUTH_COOKIE_REFRESH"],
                value=refresh_token,
                domain=settings.SIMPLE_JWT["AUTH_COOKIE_DOMAIN"],
                path=settings.SIMPLE_JWT["AUTH_COOKIE_PATH"],
                expires=datetime.datetime.utcnow() + settings.SIMPLE_JWT["REFRESH_TOKEN_LIFETIME"],
                secure=settings.SIMPLE_JWT["AUTH_COOKIE_SECURE"],
                httponly=settings.SIMPLE_JWT["AUTH_COOKIE_HTTP_ONLY"],
                samesite=settings.SIMPLE_JWT["AUTH_COOKIE_SAMESITE"],
            )
            return response
        except TokenError as e:
            return Response({"detail": "Invalid refresh token."}, status=status.HTTP_400_BAD_REQUEST)

 

auth/urls.py

authフォルダ内に新規でurls.pyを作成します。ここには先ほど作成した各種Viewのエンドポイントを定義します。

from django.urls import path

from .views import LoginView, LogoutView, RefreshTokenView, TokenVerifyView

urlpatterns = [
    path("login/", LoginView.as_view(), name="login"),
    path("logout/", LogoutView.as_view(), name="logout"),
    path("verify/", TokenVerifyView.as_view(), name="token_verify"),
    path("refresh/", RefreshTokenView.as_view(), name="token_refresh"),
]

 

config/settings.py

変更箇所を記載します。本番環境で使用する場合、SIMPLE_JWTのCOOKIEの設定は適切なものを選択する必要があると思います。詳細についてはドキュメントを参考にしてください。

from datetime import timedelta

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'corsheaders',  # add
    'rest_framework',  # add
    'djoser',  # add
    'users',  # add
]

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'corsheaders.middleware.CorsMiddleware',  # add
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

# Custom User
AUTH_USER_MODEL = 'users.CustomUser'

# Cors
CORS_ORIGIN_WHITELIST = [
    'http://localhost:3000',
    'http://localhost:8000',
]

CORS_ALLOW_CREDENTIALS = True

# Django Rest Framework
REST_FRAMEWORK = {
    'DEFAULT_PERMISSION_CLASSES': [
        'rest_framework.permissions.IsAuthenticated'
    ],
    'DEFAULT_AUTHENTICATION_CLASSES': (
        'auth.authenticate.CustomJWTAuthentication',
        'rest_framework_simplejwt.authentication.JWTAuthentication',
    ),
}

SIMPLE_JWT = {
    'AUTH_HEADER_TYPES': ('JWT',),
    'ACCESS_TOKEN_LIFETIME': timedelta(minutes=5),
    'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
    'AUTH_TOKEN_CLASSES': (
        'rest_framework_simplejwt.tokens.AccessToken',
    ),

    # cookie settings
    'AUTH_COOKIE': 'accessToken',  # cookie name
    'AUTH_COOKIE_REFRESH': 'refreshToken',  # Cookie name. Enables cookies if value is set.
    'AUTH_COOKIE_DOMAIN': None,  # specifies domain for which the cookie will be sent
    'AUTH_COOKIE_SECURE': True,  # restricts the transmission of the cookie to only occur over secure (HTTPS) connections.
    'AUTH_COOKIE_HTTP_ONLY': True,  # prevents client-side js from accessing the cookie
    'AUTH_COOKIE_PATH': '/',  # URL path where cookie will be sent
    'AUTH_COOKIE_SAMESITE': 'Lax',  # specifies whether the cookie should be sent in cross site requests
}

 

config/urls.py

authとdjoserをapi/auth/エンドポイントで受けれるようにurls.pyを修正します。

from django.contrib import admin
from django.urls import include, path

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/auth/', include('auth.urls')),
    path('api/auth/', include('djoser.urls')),
]

 

データベースのマイグレーション

以下のコマンドでデータベースのマイグレーションを行います。

python manage.py makemigrations
python manage.py migrate

 

バックエンドの起動

以下のコマンドでバックエンドを起動します。

python manage.py runserver

 

フロントエンド

ReactとTypeScriptを使用してフロントエンドの実装を行います。主な目的は、JSON Web Tokens(JWT)を用いたセキュアなユーザー認証システムの構築です。具体的には、ユーザーログイン、ログアウト、サインアップ機能を実装します。

Reactプロジェクトの作成

npx create-react-app --template typescript frontend

 

開発に必要なパッケージのインストール

muiを用いてデザインを実装します。APIリクエストにはformikやaxiosを使用し、ルーティングにreact-router-domを使用します。

cd frontend
npm install @mui/material @mui/icons-material @emotion/react @emotion/styled
npm install react-router-dom formik yup axios

 

src一覧

srcには以下のようなディレクトリ構成でファイルを実装していきます。

frontend/src/
├── App.css
├── App.test.tsx
├── App.tsx
├── components
│   ├── FormikTextField.tsx
│   ├── Loading.tsx
│   └── SubmitButton.tsx
├── features
│   ├── auth
│   │   ├── AuthContext.tsx
│   │   ├── PrivateRoute.tsx
│   │   ├── Signin.tsx
│   │   ├── Signout.tsx
│   │   ├── Signup.tsx
│   │   ├── api.ts
│   │   └── types.ts
│   └── home
│       └── Home.tsx
├── index.css
├── index.tsx
├── layout
│   ├── AppLayout.tsx
│   ├── components
│   │   ├── Main.tsx
│   │   ├── Sidebar.tsx
│   │   └── Topbar.tsx
│   └── constants.ts
├── logo.svg
├── react-app-env.d.ts
├── reportWebVitals.ts
├── setupTests.ts
└── theme
    └── index.ts

 

components/FormikTextField.tsx

フォーム処理においてFormikライブラリとMUIを組み合わせたカスタムフォームフィールドコンポーネントを定義しています。ジェネリック型Tを使用することで、任意のフォームデータ型に対応するように実装しています。

import { TextField } from "@mui/material";
import { FormikProps } from "formik";

interface FormikTextFieldProps<T> {
  name: keyof T;
  label: string;
  formik: FormikProps<T>;
  variant?: "outlined" | "filled" | "standard";
  type?: "text" | "password";
  autoComplete?: "new-password";
}

// ジェネリック型をコンポーネントに適用
export const FormikTextField = <T extends {}>(
  props: FormikTextFieldProps<T>
) => {
  return (
    <TextField
      size="small"
      id={String(props.name)}
      name={String(props.name)}
      label={String(props.label)}
      type={String(props.type)}
      autoComplete={String(props.autoComplete)}
      variant={props.variant}
      fullWidth
      value={props.formik.values[props.name]}
      onChange={props.formik.handleChange}
      onBlur={props.formik.handleBlur}
      error={
        !!(props.formik.touched[props.name] && props.formik.errors[props.name])
      }
      helperText={String(
        props.formik.touched[props.name] && props.formik.errors[props.name]
          ? props.formik.errors[props.name]
          : ""
      )}
    />
  );
};

 

components/Loading.tsx

処理の経過を示すためのローディング処理を実装します。

import { Box, CircularProgress } from "@mui/material";
import React from "react";

interface LoadingProps {
  height?: string;
}

const Loading: React.FC<LoadingProps> = ({ height = "100vh" }) => {
  return (
    <Box
      display="flex"
      justifyContent="center"
      alignItems="center"
      height={height}
    >
      <CircularProgress />
    </Box>
  );
};

export default Loading;

 

features/auth/AuthContext.tsx

認証状態を管理するためのコンテキスト(AuthContext)とプロバイダ(AuthProvider)を定義します。アプリケーション全体でユーザーの認証状態を管理し、認証が必要なコンポーネントで簡単に認証状態に基づいたレンダリングやリダイレクトを行うことができます。

import React, { createContext, useState, useContext, useEffect } from "react";
import { fetchAsyncTokenRefresh, fetchAsyncTokenVerify } from "./api";

// AuthContextの型を定義
interface AuthContextProps {
  isAuth: boolean;
  isLoading: boolean;
  signin: () => void;
  signout: () => void;
}
const AuthContext = createContext<AuthContextProps>({
  isAuth: false,
  isLoading: true,
  signin: () => {},
  signout: () => {},
});

export const useAuthContext = () => {
  return useContext(AuthContext);
};

export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
  children,
}) => {
  const [isAuth, setIsAuth] = useState(false);
  const [isLoading, setIsLoading] = useState(true);

  // ユーザーがログインした時に呼び出される関数
  const signin = () => {
    setIsAuth(true);
  };

  // ユーザーがログアウトした時に呼び出される関数
  const signout = () => {
    setIsAuth(false);
  };

  useEffect(() => {
    const verifyUser = async () => {
      // アクセストークンを使用してユーザー情報を取得するAPIリクエスト
      try {
        const response = await fetchAsyncTokenVerify();
        setIsLoading(false);
        setIsAuth(true);
        return response;
      } catch (error: any) {
        if (error.response && error.response.status === 401) {
          try {
            // リフレッシュトークンを使用して新しいアクセストークンを取得
            await fetchAsyncTokenRefresh();
            // 新しいアクセストークンでユーザー情報取得のリクエストを再試行
            const retryResponse = await fetchAsyncTokenVerify();
            setIsLoading(false);
            setIsAuth(true);
            return retryResponse;
          } catch (error: any) {
            setIsLoading(false);
            setIsAuth(false);
          }
        }
      }
    };

    verifyUser();
  }, []);

  return (
    <AuthContext.Provider value={{ isAuth, isLoading, signin, signout }}>
      {children}
    </AuthContext.Provider>
  );
};

 

features/auth/PrivateRoute.tsx

認証されたユーザーのみがアクセスできるようするためにプライベートルートを実装します。これはAuthContextで認証されている場合のみ、任意のコンポーネントにアクセスでき、認証されていない場合はSigninページにリダイレクトされます。

import React from "react";
import { useAuthContext } from "./AuthContext";
import Loading from "../../components/Loading";
import { Navigate } from "react-router-dom";

interface PrivateRouteProps {
  element: React.ReactElement;
}

export const PrivateRoute: React.FC<PrivateRouteProps> = ({ element }) => {
  const { isAuth, isLoading } = useAuthContext();
  if (isLoading) {
    return <Loading />;
  }

  if (isAuth) {
    return element;
  }

  return <Navigate to="/signin" />;
};

 

features/auth/Signin.tsx

サインインを実装します。

import {
  Avatar,
  Box,
  Button,
  Grid,
  Paper,
  Stack,
  Typography,
} from "@mui/material";
import { useFormik } from "formik";
import React, { useLayoutEffect, useState } from "react";
import * as Yup from "yup";

import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
import { Link, useNavigate } from "react-router-dom";
import { FormikTextField } from "../../components/FormikTextField";
import { fetchAsyncLoginUser } from "./api";
import { useAuthContext } from "./AuthContext";
import Loading from "../../components/Loading";

const validationSchema = Yup.object().shape({
  email: Yup.string()
    .email("Invalid email address")
    .required("Email is required"),
  password: Yup.string().required("Password is required"),
});

const Signin: React.FC = () => {
  const navigate = useNavigate();
  const { isAuth, isLoading, signin } = useAuthContext();
  const [loginError, setLoginError] = useState("");

  const formik = useFormik({
    initialValues: {
      email: "",
      password: "",
    },
    validationSchema,
    onSubmit: async (state) => {
      try {
        await fetchAsyncLoginUser(state.email, state.password);
        signin();
        navigate("/");
      } catch (error: any) {
        setLoginError(error.detail || "Signin failed. Please try again.");
      }
    },
  });

  useLayoutEffect(() => {
    if (isAuth) {
      navigate("/");
    }
  }, [isAuth, navigate]);

  if (isLoading) {
    return <Loading />;
  }

  return (
    <Grid>
      <Paper
        elevation={3}
        sx={{
          p: 4,
          height: "100%",
          width: "360px",
          m: "20px auto",
        }}
      >
        <Box sx={{ width: "100%", display: "flex", justifyContent: "center" }}>
          <Avatar>
            <LockOutlinedIcon />
          </Avatar>
        </Box>
        <Typography>Signin</Typography>
        <form noValidate onSubmit={formik.handleSubmit}>
          <Stack spacing={2}>
            <FormikTextField
              name="email"
              label="Email *"
              variant="standard"
              formik={formik}
            />
            <FormikTextField
              name="password"
              label="Password *"
              variant="standard"
              type="password"
              formik={formik}
            />
            <Button fullWidth variant="contained" type="submit">
              Signin
            </Button>
            {loginError && (
              <div style={{ color: "red", marginTop: "10px" }}>
                {loginError}
              </div>
            )}
            <Link to={"/signup"}>Create account</Link>
          </Stack>
        </form>
      </Paper>
    </Grid>
  );
};

export default Signin;

 

features/auth/Signout.tsx

サインアウトを実装します。

import React from "react";

import {
  List,
  ListItem,
  ListItemButton,
  ListItemIcon,
  ListItemText,
} from "@mui/material";
import LogoutIcon from "@mui/icons-material/Logout";
import { fetchAsyncLogoutUser } from "./api";
import { useNavigate } from "react-router-dom";
import { useAuthContext } from "./AuthContext";

const Signout: React.FC = () => {
  const navigate = useNavigate();
  const { signout } = useAuthContext();

  const handleLogout = async () => {
    await fetchAsyncLogoutUser();
    signout();
    navigate("/signin");
  };

  return (
    <List>
      <ListItem key="signout" disablePadding>
        <ListItemButton onClick={handleLogout}>
          <ListItemIcon>
            <LogoutIcon />
          </ListItemIcon>
          <ListItemText primary="Signout" />
        </ListItemButton>
      </ListItem>
    </List>
  );
};

export default Signout;

 

features/auth/Signup.tsx

ユーザーを作成するためのサインアップフォームを実装します。

import {
  Avatar,
  Box,
  Button,
  Grid,
  Paper,
  Stack,
  Typography,
} from "@mui/material";
import { useFormik } from "formik";
import React, { useLayoutEffect } from "react";
import * as Yup from "yup";

import LockOutlinedIcon from "@mui/icons-material/LockOutlined";

import { Link, useNavigate } from "react-router-dom";
import { useAuthContext } from "./AuthContext";
import Loading from "../../components/Loading";
import { FormikTextField } from "../../components/FormikTextField";
import { fetchAsyncSignup } from "./api";

const Signup: React.FC = () => {
  const { isAuth, isLoading } = useAuthContext();
  const navigate = useNavigate();
  const validationSchema = Yup.object().shape({
    firstName: Yup.string().required("First Name is required"),
    lastName: Yup.string().required("Last Name is required"),
    email: Yup.string().required("Email is required"),
    password: Yup.string()
      .required("Password is required")
      .min(8, "Password must be at least 8 characters long"),
    confirmPassword: Yup.string()
      .oneOf([Yup.ref("password")], "Passwords must match")
      .required("Confirm password is required"),
  });

  const formik = useFormik({
    initialValues: {
      firstName: "",
      lastName: "",
      email: "",
      password: "",
      confirmPassword: "", // 確認用パスワードフィールドを追加
    },
    validationSchema,
    onSubmit: async (state) => {
      await fetchAsyncSignup(state);
      navigate("/");
    },
  });

  useLayoutEffect(() => {
    if (isAuth) {
      navigate("/");
    }
  }, [isAuth, navigate]);

  if (isLoading) {
    return <Loading />;
  }

  return (
    <Grid>
      <Paper
        elevation={3}
        sx={{
          p: 4,
          height: "100%",
          width: "360px",
          m: "20px auto",
        }}
      >
        <Box sx={{ width: "100%", display: "flex", justifyContent: "center" }}>
          <Avatar>
            <LockOutlinedIcon />
          </Avatar>
        </Box>
        <Typography>Signup</Typography>
        <form noValidate onSubmit={formik.handleSubmit}>
          <Stack spacing={2}>
            <FormikTextField
              name="firstName"
              label="First Name *"
              variant="standard"
              formik={formik}
            />
            <FormikTextField
              name="lastName"
              label="Last Name *"
              variant="standard"
              formik={formik}
            />
            <FormikTextField
              name="email"
              label="Email *"
              variant="standard"
              formik={formik}
            />
            <FormikTextField
              name="password"
              label="Password *"
              variant="standard"
              type="password"
              autoComplete="new-password"
              formik={formik}
            />
            <FormikTextField
              name="confirmPassword"
              label="Confirm Password *"
              variant="standard"
              type="password"
              autoComplete="new-password"
              formik={formik}
            />
            <Button fullWidth variant="contained" type="submit">
              Submit
            </Button>
            <Link to={"/signin"}>Or Sign in</Link>
          </Stack>
        </form>
      </Paper>
    </Grid>
  );
};

export default Signup;

 

features/auth/api.ts

Django側で実装したエンドポイントにリクエストを送るための、APIリクエストを実装します。

import axios from "axios";
import { SignupUser } from "./types";

export const fetchAsyncLoginUser = async (email: string, password: string) => {
  try {
    const response = await axios.post(
      `http://localhost:8000/api/auth/login/`,
      {
        email,
        password,
      },
      {
        headers: {
          "Content-Type": "application/json",
        },
        withCredentials: true,
      }
    );
    return response.data;
  } catch (error: any) {
    throw error.response.data;
  }
};

export const fetchAsyncLogoutUser = async () => {
  try {
    await axios.post(
      `http://localhost:8000/api/auth/logout/`,
      {}, // 空のPOSTリクエストを使用
      {
        headers: {
          "Content-Type": "application/json",
        },
        withCredentials: true,
      }
    );
  } catch (error: any) {
    throw error.response.data;
  }
};

export const fetchAsyncTokenVerify = async () => {
  const response = await axios.post(
    "http://localhost:8000/api/auth/verify/",
    {},
    {
      headers: {
        "Content-Type": "application/json",
      },
      withCredentials: true,
    }
  );
  return response.data;
};

export const fetchAsyncTokenRefresh = async () => {
  await axios.post(
    "http://localhost:8000/api/auth/refresh/",
    {},
    {
      headers: {
        "Content-Type": "application/json",
      },
      withCredentials: true,
    }
  );
};

export const fetchAsyncSignup = async (props: SignupUser) => {
  const formedData = {
    first_name: props.firstName,
    last_name: props.lastName,
    email: props.email,
    password: props.password,
  };
  await axios.post("http://localhost:8000/api/auth/users/", formedData, {
    headers: {
      "Content-Type": "application/json",
    },
    withCredentials: true,
  });
};

 

features/auth/types.ts

Userに関する型を定義します。

export interface SignupUser {
  firstName: string;
  lastName: string;
  email: string;
  password: string;
  confirmPassword: string;
}

 

features/home/Home.tsx

ログイン後にリダイレクトするための、ホームを実装します。ここでは特に何もしません。

import React from "react";

const Home: React.FC = () => {
  return <div>Home</div>;
};

export default Home;

 

layout/AppLayout.tsx

デザインを統一するためのコンポーネントを実装します。メイン、サイドバー、トップバーで構成されます。

import { Box } from "@mui/material";
import React, { useState } from "react";
import { Outlet } from "react-router-dom";
import Sidebar from "./components/Sidebar";
import Topbar from "./components/Topbar";
import Main from "./components/Main";

const AppLayout: React.FC = () => {
  const [open, setOpen] = useState(true);
  const handleDrawerOpenClose = () => {
    setOpen(!open);
  };

  return (
    <Box sx={{ display: "flex" }}>
      <Sidebar open={open} />
      <Topbar open={open} handleOpenClose={handleDrawerOpenClose} />
      <Main open={open}>
        <Outlet />
      </Main>
    </Box>
  );
};

export default AppLayout;

 

layout/constants.ts

サイドバーの幅の固定値を定義します。

export const DRAWWIDTH = "280px";

 

layout/components/Main.tsx

メインを実装します。

import { Box } from "@mui/material";
import React from "react";
import { DRAWWIDTH } from "../constants";

interface MainProps {
  open: boolean;
  children: React.ReactNode;
}

const Main: React.FC<MainProps> = (props) => {
  return (
    <Box
      component="main"
      sx={{
        flexGrow: 1,
        overflowX: "auto",
        height: "100vh",
        pt: 10,
        px: 3,
        pb: 3,
        transition: (theme) =>
          theme.transitions.create("margin", {
            easing: theme.transitions.easing.sharp,
            duration: theme.transitions.duration.leavingScreen,
          }),
        marginLeft: `-${DRAWWIDTH}`,
        ...(props.open && {
          transition: (theme) =>
            theme.transitions.create("margin", {
              easing: theme.transitions.easing.easeOut,
              duration: theme.transitions.duration.enteringScreen,
            }),
          marginLeft: 0,
        }),
      }}
    >
      {props.children}
    </Box>
  );
};

export default Main;

 

layout/components/Sidebar.tsx

サイドバーを実装します。

import Box from "@mui/material/Box";
import Drawer from "@mui/material/Drawer";
import CssBaseline from "@mui/material/CssBaseline";
import Toolbar from "@mui/material/Toolbar";
import List from "@mui/material/List";
import Divider from "@mui/material/Divider";
import ListItem from "@mui/material/ListItem";
import ListItemButton from "@mui/material/ListItemButton";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";

import { useNavigate } from "react-router-dom";
import React from "react";
import { DRAWWIDTH } from "../constants";
import RecyclingOutlinedIcon from "@mui/icons-material/RecyclingOutlined";
import Signout from "../../features/auth/Signout";

interface SidebarProps {
  open: boolean;
}

const SidebarListItems = [
  {
    text: "hoge",
    icon: <RecyclingOutlinedIcon />,
    to: "/hoge",
  },
];

const Sidebar: React.FC<SidebarProps> = (props) => {
  const navigate = useNavigate();

  const handleItemClick = (to: string) => {
    navigate(to);
  };

  return (
    <Box>
      <CssBaseline />
      <Drawer
        sx={{
          width: DRAWWIDTH,
          flexShrink: 0,
          "& .MuiDrawer-paper": {
            width: DRAWWIDTH,
            boxSizing: "border-box",
          },
        }}
        variant="persistent"
        anchor="left"
        open={props.open}
      >
        <Toolbar
          variant="regular"
          sx={{ fontSize: "22px", cursor: "pointer" }}
          onClick={() => navigate("/")}
        >
          App
        </Toolbar>
        <Divider />
        <Box
          sx={{
            height: "100%",
            display: "flex",
            flexDirection: "column",
            justifyContent: "space-between",
          }}
        >
          <Box>
            <List>
              {SidebarListItems.map((item, index) => (
                <ListItem key={item.text} disablePadding>
                  <ListItemButton onClick={() => handleItemClick(item.to)}>
                    <ListItemIcon>{item.icon}</ListItemIcon>
                    <ListItemText primary={item.text} />
                  </ListItemButton>
                </ListItem>
              ))}
            </List>
            <Divider />
          </Box>
          <Signout />
        </Box>
      </Drawer>
    </Box>
  );
};

export default Sidebar;

 

layout/components/Topbar.tsx

トップバーを実装します。

import React from "react";
import { Box, IconButton, Toolbar } from "@mui/material";
import MuiAppBar from "@mui/material/AppBar";
import MenuIcon from "@mui/icons-material/Menu";
import { DRAWWIDTH } from "../constants";

interface TopbarProps {
  open: boolean;
  handleOpenClose: () => void;
}

const Topbar: React.FC<TopbarProps> = (props) => {
  return (
    <MuiAppBar
      component={Box}
      sx={{
        transition: (theme) =>
          theme.transitions.create(["margin", "width"], {
            easing: theme.transitions.easing.sharp,
            duration: theme.transitions.duration.leavingScreen,
          }),
        ...(props.open && {
          width: `calc(100% - ${DRAWWIDTH})`,
          marginLeft: `${DRAWWIDTH}`,
          transition: (theme) =>
            theme.transitions.create(["margin", "width"], {
              easing: theme.transitions.easing.easeOut,
              duration: theme.transitions.duration.enteringScreen,
            }),
        }),
      }}
    >
      <Toolbar>
        <Box
          sx={{
            width: "100%",
            display: "flex",
            justifyContent: "space-between",
          }}
        >
          <Box>
            <IconButton
              color="inherit"
              aria-label="open drawer"
              onClick={props.handleOpenClose}
              edge="start"
            >
              <MenuIcon />
            </IconButton>
          </Box>
        </Box>
      </Toolbar>
    </MuiAppBar>
  );
};

export default Topbar;

 

theme/index.ts

MUIのデフォルト色を別の色に変更します。

import { createTheme as createMuiTheme } from "@mui/material";

const themeOptions = {
  palette: {
    primary: {
      main: "#52658f",
      light: "#7483A5",
      dark: "#394664",
      contrastText: "#FFFFFF",
    },
    secondary: {
      main: "#f7f5e6",
      light: "#F8F7EB",
      dark: "#ACABA1",
      contrastText: "rgba(0, 0, 0, 0.87)",
    },
    background: {
      default: "#e8e8e8",
    },
  },
};

export const createTheme = () => {
  return createMuiTheme(themeOptions);
};

 

App.tsx

アプリにレイアウトやルーティングを適用します。ホームに関してはプライベートルートを適用するため、認証されたユーザーのみアクセスすることができます。

import "./App.css";
import { Route, Routes } from "react-router-dom";
import Signin from "./features/auth/Signin";
import AppLayout from "./layout/AppLayout";
import { PrivateRoute } from "./features/auth/PrivateRoute";
import Home from "./features/home/Home";
import Signup from "./features/auth/Signup";
import { CssBaseline, ThemeProvider } from "@mui/material";
import { createTheme } from "./theme";

function App() {
  const theme = createTheme();
  return (
    <div className="App">
      <ThemeProvider theme={theme}>
        <CssBaseline />
        <Routes>
          <Route path="/" element={<PrivateRoute element={<AppLayout />} />}>
            <Route index element={<Home />} />
          </Route>
          <Route path="/signin" element={<Signin />} />
          <Route path="/signup" element={<Signup />} />
        </Routes>
      </ThemeProvider>
    </div>
  );
}

export default App;

 

index.tsx

最後にindex.tsxを修正し、アプリを起動します。

import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import App from "./App";
import reportWebVitals from "./reportWebVitals";
import { BrowserRouter } from "react-router-dom";
import { AuthProvider } from "./features/auth/AuthContext";

const root = ReactDOM.createRoot(
  document.getElementById("root") as HTMLElement
);
root.render(
  <React.StrictMode>
    <AuthProvider>
      <BrowserRouter>
        <App />
      </BrowserRouter>
    </AuthProvider>
  </React.StrictMode>
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

 

アプリの起動は以下のコマンドで行います。

npm start

おすすめなUdemyコース

DjangoとReactを使ってフルスタックに開発を行いたい方は以下のUdemyコースもおすすめです。私が初めてDjangoとReactのアプリを開発する際に、一番最初に取り組んだものになります。体系的に説明されているため、基礎と実践を同時に学ぶことができます。

useStateやuseEffectって何?corsを設定しないとドメインが異なるリクエストを許可しない?など基礎的な部分について、さらに深めた知識を得ることができます。

icon

まとめ

この記事では、Djangoをバックエンドとして、Reactをフロントエンドに使用するWebアプリケーションにおいて、JSON Web Tokens(JWT)をCookieで管理し、認証システムを構築する方法について紹介しました。djangorestframework-simplejwtのTokenObtainPairViewを継承し、バックエンド側でcookieにJWTを設定する処理を記述しました。

フロントエンドに関しては趣味や好み、実装のしやすさ、慣れなど自分好みのレイアウトやデザインに変えることができるので、あくまで一例として捉えていただければと思います。