🦞

ReactとDjangoで認証機能作ってみた(ログイン機能編)

2024/09/25に公開

元美容師のDjangoポートフォリオリニューアル日記Part.8:認証機能(ログイン機能編)

はじめに

こんにちは!やぎです!
引き続きReact,Djangoを使ってポートフォリオ制作を実施しています!
前回の記事から認証周りの実装を開始し、サインアップ機能の実装までは完成しました。
なぜこの開発を始めたか、要件定義、その他の実装の過程も記事にしていますので読んでいただけたら幸いです。

今回は認証の続き、「ログイン機能」の実装をします。
このログイン機能が完成すれば一旦基本的なユーザー認証の土台が完成します。

それでは今回も頑張ります!実装開始!

使用する認証システムについて

ログインの際に使用する認証システムはJWT(JSON Web Token)を使用します。
JWTはアクセストークンとリフレッシュトークンの2種類のトークンを発行して、認証を管理する仕組みです。
アクセストークンを発行されたユーザーは指定された期間のみAPIのエンドポイントにアクセスすることができるようになり、リフレッシュトークンはそのアクセストークンが期限切れになった際にを再発行するためのトークンです。

今回はアクセストークンの有効期限を1時間、リフレッシュトークンの有効期限を1日に設定します。
それぞれ簡単に例えると1時間限定の入場チケット(アクセストークン)と、その入場チケットを何度でも発行できる1dayパス(リフレッシュトークン)のようなイメージです。

バックエンド(Django)の実装

DjangoRestFrameworkSimpleJWTをインストール

DjangoにはJWT認証機能を扱うための便利なライブラリがあります。
それがDjangoRestFrameworkSimpleJWTです。JWTに必要なトークンの認証や生成機能を兼ね備えています。

ターミナルで以下のコマンドを入力し、djangorestframework-simplejwtをインストールします

pip install djangorestframework-simplejwt

settibgs.pyにJWTの設定を追加

続いてsettings.pyにインストールしたdjangorestframework-simplejwtの登録と認証機能はJWTを追加いますよと設定を追記します。

INSTALLED_APPS = [
    #{その他のインストールアプリ}
    'rest_framework_simplejwt', #追記
]


REST_FRAMEWORK = { #認証クラスをJWT認証に設定しています。
    'DEFAULT_AUTHENTICATION_CLASSES': (
        'rest_framework_simplejwt.authentication.JWTAuthentication',
    )
}

from datetime import timedelta
SIMPLE_JWT = {
    'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60), #アクセストークンの有効期限を60分に設定しています
    'REFRESH_TOKEN_LIFETIME': timedelta(days=1), #リフレッシュトークンの有効期限を1日に設定しています
}

urls.pyにAPIエンドポイントを追加

urls.pyにアクセストークンとリフレッシュトークンを発行するためのAPIエンドポイントを追加します。
ユーザー名とパスワードでそれぞれのトークンを発行するエンドポイントと、リフレッシュトークンを使用してアクセストークンを再発行するためのエンドポイントをそれぞれ追記しました。

from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView

urlpatterns = [
    # 既存のURL
    path('token/', TokenObtainPairView.as_view(), name='token_obtain_pair'), #ここでユーザー名とパスワードを送信してそれぞれのトークンを発行します。いわゆるログインです。
    path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), #ここではリフレッシュトークンを利用してアクセストークンを発行します。
]

ログインしているユーザーの情報を取得するためのViewを追記

ログインしたユーザーの情報を取得するためのUserInfoViewをviews.pyに追記します。
例えばログイン中のユーザー名を画面に表示する際にここで取得したユーザー情報を使用できます。

views.pyに以下を追記します。

from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from .serializers import UserSerializer

#{その他のView}

class UserInfoView(APIView): #追記
    permission_classes = [IsAuthenticated] #ログイン中のユーザーのみアクセス可能とする

    def get(self, request):
        serializer = UserSerializer(request.user) 
        return Response(serializer.data) #getメソッドでユーザーの情報を取得

permission_classes = [IsAuthenticated]はログイン中のユーザーでないとこのViewを使用できないように指定しています。
getメソッドでユーザーの情報を取得しています。

UserInfoViewを使用するためのエンドポイントをurls.pyに追記

度々urls.pyに戻ってしまい申し訳ないです。
先ほど作成したUserInfoViewを使用するためのエンドポイントをurls.pyに追記します。

urlpatterns = [
    #{その他のエンドポイント}
    path('user-info/', UserInfoView.as_view(), name='user_info'),
]

これでバックエンドの実装は一旦完了です。
このあとAPIが正しく動作するかテストを実施します。

PostmanでAPIテストを実施

ではバックエンドの実装が一旦完了したので、恒例のAPIテストをPostmanを使用して実施してみたいと思います。
今まではシンプルなAPIのテストだったのですが、今回はユーザー情報やトークンの発行などがあるのでテストのステップを記載します。

テスト手順
事前準備.前回作成したサインアップ機能を使用してユーザー登録を実施
1.登録したユーザー名とパスワードを/api/token/にPOSTしてトークンが発行されるかチェック
2./api/user-info/に2で発行されたトークンを使用してユーザー情報が取得できるかチェック
3./api/token/refresh/で取得したリフレッシュトークンを使用し、新しいアクセストークンが取得できるかチェック

上記の手順で実施します!

それでは順番に実施してみます。

APIテスト事前準備.サインアップ機能を使用してユーザー登録を実施

まずは今回のテストで使用するためのユーザーの登録を実施します。
前回作成したサインアップを使用します!

サインアップセレクト画面
今回はサロンオーナーで登録します

ユーザー情報を入力して登録をクリックします

登録が完了しました

念の為Djangoの管理画面でユーザーが登録されているかも確認します

無事に登録されていますね!
今回登録したユーザー情報は以下の通りです。

ユーザー名:user_login
メールアドレス:user_login@mail.com
パスワード:loginpass
電話番号:111-2222-3333
紹介文:ログイン機能のテスト

APIテスト1.登録したユーザー名とパスワードを/api/token/にPOSTしてトークンが発行されるかチェック

それでは登録したユーザー名とパスワードをJson形式で以下のURLにPOSTでリクエストをします

http://localhost:8000/api/token/
{
    "username": "user_login",
    "password": "loginpass"
}

Sendをクリックします。うまくいくでしょうか。。。。

くうううう、、
404 Not Foundが帰ってきてしまいました。。。

原因を探ってみます。

原因がわかりました。。。
urls.pyに追記したコードが間違えていました。(手順で説明したurls.pyのコードは修正済みです)
urls.pyの親ディレクトリである"api/"を入れてしまっていたことが原因です。
このような時にいつも思うのはエラーをだしてくれるのってありがたいなと思います。
美容師の仕事だとお客様がエラーを出してはくれないので、ミスったら詰みですww

余談はさておきコードを修正します

#誤ったコード
urlpatterns = [
    path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
    path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
    path('api/user-info/', UserInfoView.as_view(), name='user_info'),
]

#正しいコード
urlpatterns = [
    path('token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
    path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
    path('user-info/', UserInfoView.as_view(), name='user_info'),

サーバを起動し直してもう一度再テストしてみます!

無事にリフレッシュトークンとアクセストークンが発行されました!!!
次のテストを実施します。

APIテスト2./api/user-info/に2で発行されたトークンを使用してユーザー情報が取得できるかチェック

次は上記でトークンを取得したユーザーの情報を取得します。簡単に言うとログインしているユーザー情報の取得です。
以下のURLにGETメソッドでリクエストします

http://localhost:8000/api/user-info/

postmanのHeadersに以下の情報を入力します。
Valueの値は Bearerの後に、半角スペース、アクセストークン(ダブルクォーテーションで囲わない)とします。

Key: Authorization
Value: Bearer{半角スペース}アクセストークン (テスト1で取得したアクセストークンを使用)

Sendをクリックします。登録したユーザーの情報が返されれば成功です。ドキドキ。。。

無事に先ほど登録したユーザー情報が返ってきました!!!

APIテスト3./api/token/refresh/で取得したリフレッシュトークンを使用し、新しいアクセストークンが取得できるかチェック

それではリフレッシュトークンを使用して新しいアクセストークンが使用できるか確かめます。
以下のURLにPOSTメソッドでリクエストをします。

http://localhost:8000/api/token/refresh/

bodyにJsonで以下の内容で入力します。Jsonなのでリフレッシュトークンをダブルクォーテーションで囲んでください。

{
  "refresh": "リフレッシュトークン"
}

Sendをクリックします。新しいアクセストークンが返されれば成功です。。。

無事に新しいアクセストークンが発行されました!

新しいアクセストークンでもユーザー情報が取得できるか確かめてみます!

無事に再発行されたアクセストークンでもユーザー情報を取得することができました。

現状の設定だと古いアクセストークンも有効期限の1時間以内であれば以下の通り使用することができます。
古いアクセストークンで再度ユーザー情報を取得した結果

一旦この仕様で進めますが、高度なセキュリティが必要な場合は、新しいトークンが発行されたら古いトークンは使用できないようにするなど、管理をより厳格に行う必要があるかもしれません。

アクセストークンの有効期限をチェック

最後にアクセストークンの有効期限である1時間後にユーザー情報が取得できなくなるかチェックします。

1時間後に再度ユーザー情報を取得

401が帰ってきたので有効期限が正しく機能しています
これでバックエンドのテストは完了です。

フロントエンド(React)の実装

バックエンド側でJWT認証の準備ができたので、次はフロントエンド側の実装に移ります。React を使用して、ログイン機能を実装していきましょう。

1. 認証状態管理(AuthContext)の実装

まず、アプリケーション全体で認証状態を管理するための AuthContext.jsを作成します。
これにより、どのコンポーネントからでも簡単に認証情報にアクセスできるようになります。

AuthContextの主な役割は以下の通りです

・ユーザーの認証状態を管理
・ログイン・ログアウト機能
・JWTトークンをローカルストレージに保存・削除
・ユーザー情報の取得

src/contexts/AuthContext.js ファイルを作成し、以下のコードを記述します

import React, { createContext, useState, useContext, useEffect } from 'react';
import axios from 'axios';

// AuthContextの作成
const AuthContext = createContext();

// カスタムフックの作成。他のコンポーネントでAuthContextを簡単に使用できるようにする
export function useAuth() {
  return useContext(AuthContext);
}

export function AuthProvider({ children }) {
  // ユーザー状態と読み込み状態の管理
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  // コンポーネントマウント時にローカルストレージからトークンを取得し、ユーザー情報を取得する
  useEffect(() => {
    const token = localStorage.getItem('access_token');
    if (token) {
      fetchUserInfo(token);
    } else {
      setLoading(false);
    }
  }, []);

  // トークンを使用してユーザー情報を取得する関数
  const fetchUserInfo = async (token) => {
    try {
      const response = await axios.get('http://localhost:8000/api/user-info/', {
        headers: { Authorization: `Bearer ${token}` }
      });
      setUser(response.data);
    } catch (error) {
      console.error('Failed to fetch user info', error);
    } finally {
      setLoading(false);
    }
  };

  // ログイン処理を行う関数
  const login = async (username, password) => {
    try {
      const response = await axios.post('http://localhost:8000/api/token/', { username, password });
      // トークンをローカルストレージに保存
      localStorage.setItem('access_token', response.data.access);
      localStorage.setItem('refresh_token', response.data.refresh);
      // ユーザー情報を取得
      await fetchUserInfo(response.data.access);
      return true;
    } catch (error) {
      console.error('Login failed', error);
      return false;
    }
  };

  // ログアウト処理を行う関数
  const logout = () => {
    // ローカルストレージからトークンを削除
    localStorage.removeItem('access_token');
    localStorage.removeItem('refresh_token');
    // ユーザー状態をクリア
    setUser(null);
  };

  // AuthContextで提供する値
  const value = {
    user,
    setUser,
    login,
    logout,
  };

  // AuthContextProviderでラップし、childrenをレンダリング
  return (
    <AuthContext.Provider value={value}>
      {!loading && children}
    </AuthContext.Provider>
  );
}

AuthContext.jsでは初めて登場した内容が多く、今回の実装の肝になる部分ですので簡単に説明を追記します。

createContext():コンテキストを作成してくれます。コンテキストとは階層が異なるコンポーネントでも受け渡し可能となります。propsは指定したコンポーネントにしか渡せないので深いネストになった際にリレー形式にしないといけないですが、コンテキストならその必要がありません。

localStorage: WebApiのひとつです。クライアント側で保存できるデータです.
例えばlocalStorage.getItem(key)ではローカルストレージに保存されたデータのkeyを指定して対象のデータを取り出しています。

async/await : async/awaitは非同期処理と呼ばれる機能です。asyncを宣言した処理の中ではawaitを宣言することができます。例えば今回の実装だとfetchUserInfo()で使用しています。
apiでユーザー情報を取得する部分でawaitを宣言しており、これはユーザー情報を取得できるまでは次の処理に進まないように制御をしています。この宣言をしないとユーザー情報を取得中に次の処理に進んでしまうので、ユーザー情報が正しく設定されない可能性があります。

AuthContext.Provider:このAuthContext.Providerで囲まれたコンポーネントはAuthContextの値にアクセスできるようになります。今回の場合はchildrenを囲っています。さらに {!loading && children}とすることでloadingがfalseの時のみchildrenをレンダリングしてくれるので、認証がされるまでは機能しないようにしています。
valueオブジェクトには、user、login、logoutの値が格納されています。

2. ログインフォームの作成

次に、ユーザーがログインするためのフォームを作成します。
このログインフォームは、ユーザー名とパスワードの入力を受け付け、先ほど作成したAuthContextのlogin関数を使用して認証を行います。ログイン成功後はホームページにリダイレクトするように設定します。

src/components/LoginForm.jsx ファイルを作成し、以下のコードを記述します:

import React, { useState } from 'react';
import { TextField, Button, Typography, Box } from '@mui/material';
import { useAuth } from '../contexts/AuthContext';
import { useNavigate } from 'react-router-dom';

function LoginForm() {
  // フォームの入力値を管理するstate
  const [formData, setFormData] = useState({
    username: '',
    password: '',
  });
  // AuthContextからlogin関数を取得
  const { login } = useAuth();
  // ナビゲーション用のフック
  const navigate = useNavigate();

  // 入力値が変更されたときの処理
  const handleChange = (e) => {
    setFormData({ ...formData, [e.target.name]: e.target.value });
  };

  // フォーム送信時の処理
  const handleSubmit = async (e) => {
    e.preventDefault();
    // login関数を呼び出し、成功したらホームページにリダイレクト
    const success = await login(formData.username, formData.password);
    if (success) {
      navigate('/');
    } else {
      console.error('Login failed');
    }
  };

  return (
    <Box component="form" onSubmit={handleSubmit} sx={{display: 'flex', flexDirection: 'column', alignItems: 'center', mt: 4}}>
      <Typography variant="h5" component="h1" gutterBottom>
        ログイン
      </Typography>
      {/* ユーザー名入力フィールド */}
      <TextField
        margin="normal"
        required
        id="username"
        label="ユーザー名"
        name="username"
        autoComplete="username"
        autoFocus
        value={formData.username}
        onChange={handleChange}
      />
      {/* パスワード入力フィールド */}
      <TextField
        margin="normal"
        required
        name="password"
        label="パスワード"
        type="password"
        id="password"
        autoComplete="current-password"
        value={formData.password}
        onChange={handleChange}
      />
      {/* ログインボタン */}
      <Button
        type="submit"
        variant="contained"
        sx={{ mt: 3, mb: 2 }}
      >
        ログイン
      </Button>
    </Box>
  );
}

export default LoginForm;

3. ヘッダーコンポーネントの実装

続いてヘッダーコンポーネントを作成し、ログイン状態に応じてユーザー名やログイン、ログアウトボタンをアプリ内で表示できるようにします。

src/components/Header.jsx ファイルを作成し、以下のコードを記述します:

import React from 'react';
import { AppBar, Toolbar, Typography, Button } from '@mui/material';
import { useAuth } from '../contexts/AuthContext';
import { useNavigate } from 'react-router-dom';

function Header() {
  // AuthContextからユーザー情報とログアウト関数を取得
  const { user, logout } = useAuth();
  // ナビゲーション用のフック
  const navigate = useNavigate();

  // ログアウト処理
  const handleLogout = () => {
    logout();
    navigate('/login');
  };

  return (
    <AppBar position="static">
      <Toolbar>
        {/* アプリケーション名 */}
        <Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
          SalonLink
        </Typography>
        {user ? (
          // ユーザーがログインしている場合
          <>
            {/* ユーザー名の表示 */}
            <Typography variant="subtitle1" sx={{ mr: 2 }}>
              {user.username}
            </Typography>
            {/* ログアウトボタン */}
            <Button color="inherit" onClick={handleLogout}>
              ログアウト
            </Button>
          </>
        ) : (
          // ユーザーがログインしていない場合
          // ログインボタン
          <Button color="inherit" onClick={() => navigate('/login')}>
            ログイン
          </Button>
        )}
      </Toolbar>
    </AppBar>
  );
}

export default Header;

アプリケーションのルーティング設定

最後に、アプリケーション全体のルーティングを設定し、認証状態に応じてアクセス制御を行います。
このApp.jsファイルでは、AuthProviderを使用してアプリケーション全体に認証コンテキストを使用できるようにしています。
さらにPrivateRouteで囲ったコンポーネントにアクセスするために認証が必要となるため、Home.jsをPrivateRouteで囲みログインしたユーザーのみアクセスできるようにしました。
またログインしていないユーザーはログインページに遷移するよう、Navigateで指定しています。

それではsrc/App.js ファイルを以下のように更新します:

import React from 'react';
import { BrowserRouter as Router, Route, Routes, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import LoginForm from './components/LoginForm';
import SignupSelect from './components/SignupSelect';
import SignupForm from './components/SignupForm';
import Home from './components/Home';
import Header from './components/Header';

// 認証が必要なルートのためのコンポーネント
function PrivateRoute({ children }) {
  const { user } = useAuth();
  // ユーザーがログインしていない場合、ログインページにリダイレクト
  return user ? children : <Navigate to="/login" />;
}

function App() {
  return (
    <AuthProvider>
      <Router>
        <Header />
        <Routes>
          {/* サインアップ関連のルート */}
          <Route path="/signup" element={<SignupSelect />} />
          <Route path="/signup/:userType" element={<SignupForm />} />
          {/* ログインページのルート */}
          <Route path="/login" element={<LoginForm />} />
          {/* ホームページのルート(認証が必要) */}
          <Route
            path="/"
            element={
              <PrivateRoute>
                <Home />
              </PrivateRoute>
            }
          />
        </Routes>
      </Router>
    </AuthProvider>
  );
}

export default App;

以上でフロントエンドの実装は完了です!
うまくログイン機能が機能しているでしょうか...
実際の画面で動作のテストをしていきましょう!

テストを実施

さて最後にログイン機能のテストを実施してみます。
少し項目が多いのですが、せっかく作成してので細かくチェックしてみようと思います。
レイアウトについては今回は最低限で作ったので機能面のみチェックします。
今回テストする項目を以下のように洗い出してみました。

正常系テスト
http://localhost:3000/loginでログインフォームが表示されること
・正しいユーザー名とパスワードでログインできることを確認
・ログイン後、ホーム画面に遷移されることを確認
・ログイン後、ヘッダーにログインユーザーの名前が表示されることを確認
・ログイン後、ログアウトボタンが表示され、クリックするとログアウトできることを確認
・ログアウトボタンを押下するとログアウトされること
・ログインしていない状態だとヘッダーにログインボタンが表示され、ユーザー名、ログアウトボタンが表示されないこと
・ログインしていない状態でホーム画面にアクセスしようとすると、ログイン画面にリダイレクトされることを確認

異常系テスト
・誤ったユーザー名やパスワードでログインを試みた場合、コンソールにエラーメッセージが表示されることを確認
・空のフォームでログインを試みた場合、コンソールにバリデーションエラーが表示されることを確認

項目は完璧じゃないかもしれませんが、一旦上記の項目でテストしてみます!
それでは一つずつチェックしてみましょう!

正常系テスト

http://localhost:3000/loginでログインフォームが表示されること

http://localhost:3000/login

上記urlにアクセスしログインフォームが表示されるかチェックします!

無事に表示されました!

正しいユーザー名とパスワードでログインできることを確認

ログイン後、ホーム画面に遷移されることを確認

登録したユーザー名とパスワードでログインできるか確かめます。
その流れでホーム画面に遷移するか確認します。

フォームにそれぞれ入力

ログインボタンを押下


無事にログインできました

ログイン後、ヘッダーにログインユーザーの名前が表示されることを確認

ログイン後、ログアウトボタンが表示され、クリックするとログアウトできることを確認

上記の2項目もここで確認します。

ユーザー名とログアウトボタンが表示されています

ログアウトボタンを押下するとログアウトされること

ログアウトボタンを押下してログアウトするか確認します。

ログアウトできました!

ログインしていない状態だとヘッダーにログインボタンが表示され、ユーザー名、ログアウトボタンが表示されないこと

ログアウトするとヘッダーの表示が変わることも確認できました。

ログインしていない状態でホーム画面にアクセスしようとすると、ログイン画面にリダイレクトされることを確認

ログアウト状態でhttp://localhost:3000/にアクセスしてもログイン画面に遷移することを確認します。以下のurl部分を確認します。

ログアウト状態だとホーム画面に遷移せずログイン画面に遷移しました。
これで正常系は完了です!!

異常系テスト

誤ったユーザー名やパスワードでログインを試みた場合、コンソールにエラーメッセージが表示されることを確認

存在しないユーザー名とパスワードでログインを試みた際に、コンソールにログインできませんと表示されるか確認します。

コンソールにエラーが表示されることを確認しました。

空のフォームでログインを試みた場合、バリデーションエラーが表示されることを確認

最後にフォームに何も入力しないでログインを試みた場合、入力必須のバリデーションが効くか確認します。

無事にバリデーションが動作しました!!
これで今回のテスト、実装は全て完了です!!!
お疲れ様でした!最後にまとめです。

最後に

今回の実装で、JWT認証を使用したログイン機能を完成させることができました。
主な実装内容は以下の通りです!

バックエンド(Django)側
DjangoRestFrameworkSimpleJWTを使用したJWT認証の設定
アクセストークンとリフレッシュトークンの発行および更新のためのエンドポイントの作成
ユーザー情報を取得するためのAPIの実装

フロントエンド(React)側
AuthContextを使用した認証状態の管理
ログインフォームの作成
ヘッダーコンポーネントでのユーザー状態表示
PrivateRouteを使用したアクセス制御の実装

これらの実装により、以下のような機能が実現しました
ユーザーがログインフォームからログインできる
ログイン状態に応じてヘッダーの表示が変わる(ユーザー名表示、ログアウトボタン)
ログアウト機能
認証が必要なページへのアクセス制御

また、Postmanを使用したバックエンドAPIのテストや、実際のブラウザでのフロントエンド動作確認を通じて、実装した機能が正しく動作することを確認しました。

今後は、このシステムを基に、より使いやすい機能を追加していくことができます。
次回は、サロンオーナーと美容師それぞれに特化した機能の実装に取り組む予定です。
引き続きSalonLinkの開発頑張りたいと思います!
最後までお読みいただきありがとうございました!!
次回の実装もお楽しみに!

やぎ

Discussion