【React + Node.js】ログイン認証機能(実装編)

JavaScript

■ 主の PC環境一覧

OS:Windows11(WSL2:Ubuntu 22.04.4 LTS)

nvm:v18.16.0
npm:v9.5.1
MySQL:8.4.0

フロントエンド側(React)の最終的なファイル構成一覧
./login-form
└── form
    ├── public
    │   ├── index.html
    │   ├── manifest.json
    │   └── robots.txt
    ├── src
    │   ├── assets
    │   │   └── styles
    │   │       └── Login.css
    │   ├── index.css
    │   ├── index.tsx
    │   ├── pages
    │   │   └── LoginForm.tsx
    │   ├── react-app-env.d.ts
    │   ├── reportWebVitals.ts
    │   └── setupTests.ts
    ├── .gitignore
    ├── .env
    ├── package-lock.json
    ├── package.json
    ├── README.md
    └── tsconfig.json
バックエンド側(Node.js)の最終的なファイル構成一覧
./login-form
└── server
    ├── app.ts
    ├── package-lock.json
    ├── package.json
    ├── src
    │   ├── config
    │   │   └── db.ts
    │   ├── controllers
    │   │   └── authLoginController.ts
    │   ├── models
    │   │   └── authLoginModel.ts
    │   └── routes
    │       └── authLoginRoutes.ts
    └── tsconfig.json

■ 初めに

今回の完成である実装を一式 GitHubにて公開していますので、よければどうぞ。

はい、頑張っていきましょう。

あのね、コンポーネントを含む実装の場合、TypeScriptではファイル拡張子を「.tsx」にしないといけないんですって。
逆に、コンポーネントを含んでいない実装の場合は「.ts」でいいんですって。

へぇ~ ですよね。
差別化ですね。

[1]フロントエンド側(React)実装

フロントエンド側で使用する(新規作成する)ファイルの作成から行います。

以下に示す「create-dev-files.sh」ファイルを「form」ディレクトリ内に作成して実行してください。

※ なお、環境構築編で設計パターンに伴う各ディレクトリを作成していることを前提とした構成です。

#!/bin/bash

files=(
".env"
"src/assets/Login.css"
"src/pages/LoginForm.tsx"
)

for file in "${files[@]}"; do
  touch "$file"
done
$ chmod +x create-dev-files.sh
$ ./create-dev-files.sh

[1.1]エントリーポイントを修正(index.tsx)

index.tsx ファイルは、React アプリケーションのエントリーポイントとして機能します。
本コードスニペットは、アプリケーションが起動するとき最初に読み込まれ、通常はアプリケーションのルートコンポーネントをレンダリングします。

import React from 'react';
import ReactDOM from 'react-dom/client';
import reportWebVitals from './reportWebVitals';
import LoginForm from './pages/LoginForm';

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);
root.render(
  <React.StrictMode>
    <LoginForm />
  </React.StrictMode>
);

reportWebVitals();
コードスニペットの詳細
  1. モジュールのインポート
    • React
      • React自体をインポートしています。
    • ReactDOM
      • React DOMライブラリをインポートしています。
        本ライブラリは、Reactコンポーネントを DOMにマッピングするためのツールを提供します。
    • reportWebVitals
      • パフォーマンス測定に関連する関数をインポートしています。
        本関数は、Webページのパフォーマンス指標を収集し、分析するために使用されます。
    • LoginForm(実装はこちら
      • ログインフォームを表示するためのコンポーネントをインポートしています。
  2. React DOMの初期化
    • ReactDOM.createRoot関数を使用して、document.getElementById('root')で取得した HTML要素に対する Reactの根ノードを生成しています。
      この方法は、コンポーネントのライフサイクルをより柔軟に制御できる新しい APIです。
  3. コンポーネントのレンダリング
    • root.render関数を使用して、LoginFormコンポーネントをレンダリングしています。
      本コンポーネントは、ユーザーがログイン情報を入力してログインできるようにするものです。
  4. パフォーマンス測定
    • reportWebVitals関数の呼び出しは、Webページのパフォーマンスを監視し、改善の余地がある箇所を特定するためのツールとして利用されます。
      これにより、アプリケーションのパフォーマンスを継続的に向上させることが可能になります。

[1.2]環境変数を定義

.envは環境変数を定義するために使用します。
これは、開発環境や本番環境で異なる設定値を簡単に切り替えることができるようにするための仕組みです。
また、「.env」ではなくても「開発環境:.env.development」や「本番環境:.env.production」のようにサフィックスを付与したファイル名でも問題ありません。

# crypto-js
REACT_APP_ENCRYPTION_USER = xxxxxxxxxx
REACT_APP_ENCRYPTION_PASS = yyyyyyyyyy

前述の後半で説明した環境変数定義ファイルの複数化において、確か、React側はひと手間ふた手間ぐらい掛けないといけなかった気がします。
Node.js側では、そんなことないんですけどね。
※ 複数ではなく、1ファイルであれば問題ないので、この場では置いておきましょう。

あと、React側での環境変数定義は、変数名のプレフィックスに「REACT_APP_」を絶対に付与しないといけないので気を付けてください。
これがないと、環境変数として読み込んでくれません。

[1.3]ログイン認証画面を実装(LoginForm.tsx)

以下のコードスニペットは、ユーザーが自身のIDとパスワードを入力してログインできるようにすることです。
具体的には、ユーザーがフォームに情報を入力すると、その情報がリアルタイムで状態に反映され、ログインボタンを押すと、入力された情報がサーバーに送られ、サーバーからのレスポンスに基づいてログイン処理が行われます。

また、本コンポーネントではユーザー情報の暗号化を行っており、これはセキュリティ上の配慮です。暗号化により、通信中の情報が第3者によって盗聴されるリスクを軽減することができます。

import React, { useState, ChangeEvent, FormEvent } from 'react';
import axios from 'axios';
import CryptoJS from 'crypto-js';
import { Container, Row, Col, Form, Button } from 'react-bootstrap';
import 'bootstrap/dist/css/bootstrap.min.css';
import '../assets/styles/Login.css';

/**
 * @brief     ログイン認証画面
 * @returns   コンポーネント
 */
const LoginForm: React.FC = () => {
  const [userInfo, setUserInfo] = useState<{username: string; password: string;}>( {
    username: '',
    password: ''
  });

  /**
   * @brief     'username'及び 'passwprd'変更ハンドラ
   * @param {*} e イベント
   */
  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target;
    setUserInfo( {
      ...userInfo,
      [name]: value
    });
  };


  /**
   * @brief     ログインボタン押下ハンドラ
   * @param {*} e イベント
   */
  const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    /**
     * --------------------------------------------------------------------------------------------------------------
     * Encryption of user information
     * --------------------------------------------------------------------------------------------------------------
     */
    const encryptedUserInfo: {
      username: string | undefined;
      password: string | undefined;
    } = {
      username: undefined,
      password: undefined
    }

    const encryptionUserKey: string | undefined = process.env.REACT_APP_ENCRYPTION_USER;
    const encryptionPassKey: string | undefined = process.env.REACT_APP_ENCRYPTION_PASS;

    encryptedUserInfo.username = CryptoJS.AES.encrypt(userInfo.username, (encryptionUserKey as string)).toString();
    encryptedUserInfo.password = CryptoJS.AES.encrypt(userInfo.password, (encryptionPassKey as string)).toString();
    /**
     * --------------------------------------------------------------------------------------------------------------
     */

    axios.post('http://localhost:5000/api/auth/login', encryptedUserInfo, {
        headers: {
          'Content-Type': 'application/json'
        }
      })
      .then((res) => {
        if (res.data.status === 200) {
          // OK
        }
        else {
          // NG
        }
        console.log(`${res.status}: ${res.headers}`)
      })
      .catch((err) => {
        console.error(`[Error] ${err}`);
      });
  };
  

  return (
    <Container fluid className="d-flex vh-100">
      <Row className="m-auto align-self-center w-100">
        <Col xs={12} md={6} lg={4} className="mx-auto">
          <div className="p-4 shadow rounded">
            <h3 className="text-center mb-4">
              ログイン認証画面
            </h3>
            <Form onSubmit={handleSubmit}>
              <Form.Group controlId="formBasicUsername" className="mb-3 mt-5">
                <Form.Control 
                  type="text" 
                  name="username" 
                  placeholder="username"
                  value={userInfo.username}
                  onChange={handleChange} 
                  required 
                  autoComplete="username" 
                />
              </Form.Group>
              <Form.Group controlId="formBasicPassword" className="mb-3 mt-4">
                <Form.Control 
                  type="password" 
                  name="password"
                  placeholder="password" 
                  value={userInfo.password}
                  onChange={handleChange} 
                  required 
                  autoComplete="current-password" 
                />
              </Form.Group>
              <Button variant="skeleton" type="submit" className="btn btn-outline-info w-100 mt-4">
                Login
              </Button>
            </Form>
          </div>
        </Col>
      </Row>
    </Container>
  );
};

export default LoginForm;
コードスニペットの詳細
  1. コンポーネントの宣言と状態の初期化
    • useStateフックを使用して、userInfoという名前のオブジェクトを保持する状態を初期化しています。
      本オブジェクトには、usernamepasswordの 2つのプロパティがあり、それぞれの値は空文字列で初期化しています。
  2. 入力フィールドの変更ハンドラ
    • handleChange関数は、ユーザーがログインフォームの usernameまたは passwordフィールドに入力するたびに呼び出されます。
      本関数は、新しい入力値を受け取り、userInfo状態を更新します。
  3. ログインフォームの送信
    • handleSubmit関数は、ログインフォームが送信されたときに呼び出されます。
      本関数内で、まずユーザー情報を暗号化し、次に暗号化された情報をサーバーに POSTリクエストとして送信します。
      暗号化には CryptoJS.AES.encrypt関数を使用しています。
  4. サーバーからのレスポンスの処理
    • サーバーからのレスポンスを受け取った後、ステータスコードが 200であるかどうかをチェックし、成功した場合には何もしない(コメントアウトされている部分)、失敗した場合にはエラーメッセージを表示します。
  5. UIコンポーネントのレンダリング
    • コンポーネントの return文では、ログインフォームの UIをレンダリングしています。
      ここでは、react-bootstrapライブラリを使用してフォームのレイアウトを整え、onChangeイベントハンドラを各入力フィールドに割り当てています。

[1.4]ログイン認証画面のスタイルシート作成

::placeholder {
	text-align: center;
}
コードスニペットの詳細
  1. セレクタ
    • ::placeholderは、CSSの疑似要素の一種で、特定の条件下でスタイルを適用することができます。
      この場合、::placeholderはプレースホルダーテキストに対してスタイルを適用します。
  2. プロパティ
    • text-align: center;は、プレースホルダーテキストの位置を中央揃えにするための CSSプロパティです。

[2]バックエンド側(Node.js)実装

バックエンド側で使用する(新規作成する)ファイルの作成から行います。

以下に示す「create-dev-files.sh」ファイルを「server」ディレクトリ内に作成して実行してください。

※ なお、環境構築編で設計パターンに伴う各ディレクトリを作成していることを前提とした構成です。

#!/bin/bash

files=(
".env"
"config/db.ts"
"controllers/authLoginController.ts"
"models/authLoginModels.ts"
"routes/authLoginRoutes.ts"
)

for file in "${files[@]}"; do
  touch "$file"
done
$ chmod +x create-dev-files.sh
$ ./create-dev-files.sh

[2.1]エンドポイントを実装(app.ts)

以下のコードスニペットは、Expressを用いて Web APIを提供するサーバーの基本的な構造を定義しています。
特に、認証関連のエンドポイントを設定し、CORSポリシーを適応することでフロントエンド側との通信を可能にします。

import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import { router as authRoutes } from './src/routes/authLoginRoutes';

dotenv.config();

const app: express.Express = express();
app.use(express.json());
app.use(cors());

// POST: ログイン認証
app.use('/api/auth', authRoutes);

const PORT = process.env.PORT || 5000;
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});
コードスニペットの詳細
  1. モジュールのインポート
    • express
      • Expressをインポートしてアプリケーションを作成します。
    • cors
      • CORS(Cross-Origin Resource Sharing)ポリシーを適用するために使用されます。
        これにより、異なるオリジンのリクエストが許可されます。
    • dotenv
      • 環境変数をロードするためのライブラリです。
        これにより、アプリケーションの設定情報を .envファイルから読み込むことができます。
    • authRoutes
      • 認証関連のルーティングを含むモジュールをインポートしています。
        このモジュールは、認証処理に関する HTTPメソッド(GET、POSTなど)に対応したエンドポイントを定義しています。
  2. 環境変数の読み込み
    • dotenv.config();
      • .envファイルから環境変数を読み込みます。
        これにより、秘密鍵やデータベース接続情報などの機密情報を安全に管理できます。
  3. Expressアプリケーションの初期化
    • const app: express.Express = express();
      • Expressアプリケーションを初期化します。
  4. ミドルウェアの設定
    • app.use(express.json());
      • リクエストボディを JSON形式として解析するミドルウェアを追加します。
    • app.use(cors());
      • CORSポリシーを有効にするミドルウェアを追加します。
  5. ルーティングの設定
    • /api/authへの POSTリクエストに対して、authRoutesを使用してルーティングを設定しています。
      これにより、ユーザーのログイン認証処理が行われます。
  6. サーバーの起動
    • app.listen(PORT, ...)で指定されたポートでサーバーを起動します。
      デフォルトではポート5000を使用しますが、環境変数 process.env.PORTが設定されている場合はその値を優先します。

[2.2]環境変数を定義

.envは環境変数を定義するために使用します。

# Database
DB_HOST     = localhost
DB_USER     = user
DB_PASSWORD = pass
DB_NAME     = workspace

# crypto-js
ENCRYPTION_USER = xxxxxxxxxx
ENCRYPTION_PASS = yyyyyyyyyy

バックエンド側ではデータベース(MySQL)へ接続を行うので、接続情報に関する定義を行っています。
また、フロントエンド側から送信される暗号化されたユーザー情報の複合化に伴い、フロントエンド側と同様のハッシュ値を定義する必要があります。
またまた、Node.jsでは、Reactのように環境変数名のプレフィックスに「REACT_APP_」は不要です。

[2.3]APIエンドポイントの設定処理を実装(authLoginRoutes.ts)

以下のコードスニペットは、Expressを使用して APIエンドポイントを設定しています。
具体的には、ログイン認証に関する POSTリクエストを処理するエンドポイントを定義しています。

import express from 'express';
import { authLogin }  from '../controllers/authLoginController';

export const router = express.Router();

// POST: ログイン認証
router.post('/login', authLogin);
コードスニペットの詳細
  1. モジュールのインポート
    • express
      • Expressをインポートしてアプリケーションを作成します。
    • authLogin
      • ログイン認証処理を担当するコントローラーをインポートしています。
  2. ルーターインスタンスの作成
    • express.Router()を使用して、ルーターインスタンスを作成しています。
      このルーターインスタンスは、複数のルート(URLパス)をグループ化し、共通のミドルウェアを適用するのに便利です。
  3. POSTリクエストのルーティング
    • router.post('/login', authLogin);で、/loginへのPOSTリクエストを受け付けるルートを定義しています。
      本リクエストは、authLoginコントローラーによって処理されます。

[2.4]ログイン認証処理を実装(authLoginController.ts)

本コードスニペットは、Expressを使用したログイン認証処理を実装するためのコントローラー関数です。
具体的には、クライアントから受け取ったユーザー情報を複合化し、その情報を使用してデータベースからユーザー情報を検索し、認証に成功した場合に成功メッセージを返す、といった処理を行っています。

import { Request, Response } from 'express';
import { RowDataPacket } from 'mysql2';
import CryptoJS from 'crypto-js';
import { getUserInfo } from '../models/authLoginModel'


/**
 * @brief     ログイン認証
 * @param req   リクエスト
 * @param res   レスポンス
 * @returns   ログイン認証結果
 */
export async function authLogin (req: Request, res: Response)
{
  let userInfo: 
  {
    username: string,
    password: string
  };
  userInfo = {...req.body};

  /**
   * ------------------------------------------------------------------------------------------------------------------------
   * Decryption of user information
   */
  const bytesUser: CryptoJS.lib.WordArray = CryptoJS.AES.decrypt(userInfo.username, (process.env.ENCRYPTION_USER as string));
  const bytesPass: CryptoJS.lib.WordArray = CryptoJS.AES.decrypt(userInfo.password, (process.env.ENCRYPTION_PASS as string));
  
  const decryptedUser: string = bytesUser.toString(CryptoJS.enc.Utf8);
  const decryptedPass: string = bytesPass.toString(CryptoJS.enc.Utf8);
  /**
   * ------------------------------------------------------------------------------------------------------------------------
   */

  try 
  {
    const [results, fields] = await getUserInfo(decryptedUser, decryptedPass);
    let rows: RowDataPacket[] = (results as RowDataPacket[]);
    
    if (rows.length === 0) 
    {
      res.status(401).json('ユーザー認証に失敗しました。');
      return;
    }
    
    res.status(200).json('ユーザー認証が成功しました。');
    rows.forEach(result => {
      console.log(`ユーザーID: ${result.id}, ユーザー名: ${result.username}, パスワード: ${result.password}`);
    });
  } 
  catch (err) 
  {
    res.status(500).json(`データベースからユーザー情報の取得に失敗しました。(${err})`);
  }

  return res;
};
コードスニペットの詳細
  1. モジュールのインポート
    • Request, Response
      • Expressからリクエストとレスポンスオブジェクトをインポートしています。
    • RowDataPacket
      • MySQL2パッケージから、MySQLの結果セットを扱うための型をインポートしています。
    • CryptoJS
      • 暗号化ライブラリをインポートしています。
        本ライブラリを使用して、ユーザー情報を複合化します。
    • getUserInfo
      • ユーザー情報をデータベースから取得するためのモデル関数をインポートしています。
  2. リクエストボディの受け取りとデコード
    • クライアントから送信されたリクエストボディ(ユーザー名とパスワード)を取得し、それらを複合化して元の情報に戻します。
      複合化には CryptoJS.AES.decrypt関数を使用しています。
  3. データベースからのユーザー情報の取得
    • 複合化されたユーザー情報を getUserInfo関数に渡して、データベースから該当するユーザー情報を検索します。
  4. 認証結果の処理
    • 検索結果がない場合(認証失敗)は、401 Unauthorizedのステータスコードとエラーメッセージをレスポンスとして返します。
    • 検索結果がある場合(認証成功)は、200 OKのステータスコードと成功メッセージをレスポンスとして返します。

[2.5]データベース操作処理を実装(authLoginModel.ts)

以下のコードスニペットは、Node.jsアプリケーションにおけるデータベース操作を実現するための関数です。
具体的には、指定されたユーザー名とパスワードに一致するユーザー情報をデータベースから検索するためのものです。

import { pool } from '../config/db';
import { QueryResult, FieldPacket } from 'mysql2';


/**
 * @brief     ユーザー情報取得
 * @param username    ユーザー名
 * @param password    パスワード
 * @returns   クエリ実行結果
 */
export async function getUserInfo (username: string, password: string,): Promise<[QueryResult, FieldPacket[]]> 
{
  const query = 'SELECT * FROM users WHERE username = ? AND password = ?';
  return pool.query(query, [username, password]);
};
コードスニペットの詳細
  1. モジュールのインポート
    • pool
      • データベース接続プールを管理するためのオブジェクトをインポートしています。
        この pool../config/dbからエクスポートされているものを使用しています。
    • QueryResult, FieldPacket
      • MySQL2パッケージから、クエリ実行結果とフィールドパケットの型をインポートしています。
  2. 関数の定義
    • getUserInfo関数は、ユーザー名とパスワードを引数に取り、Promiseを返す非同期関数です。
      本関数は、データベースからユーザー情報を検索するクエリを実行し、その結果を返します。
  3. SQLクエリの構築
    • 複合化されたユーザー情報を getUserInfo関数に渡して、データベースから該当するユーザー情報を検索します。
  4. 認証結果の処理
    • SQLクエリ文字列を定義し、?プレースホルダーを使用してユーザー名とパスワードを挿入します。
      これにより、SQLインジェクション攻撃を防ぐことができます。
  5. クエリの実行と結果の返却
    • pool.query関数を使用して、構築したクエリを実行し、その結果を返します。
      本関数は非同期的に動作し、Promiseを返します。

■ 実行

□ データベース側(MySQL)

# 今回、開発環境編で作成したコンテナIDを検索
$ docker ps -a | grep ws_mysql

---
fasdoifuasdf   mysql:latest                   "docker-entrypoint.s…"   12 days ago     Exited (0) 47 seconds ago                 ws_mysql


# 該当のコンテナIDを指定して起動
$ docker start fasdoifuasdf

□ フロントエンド側(React)

package.json内に以下の設定があることを今一度ご確認ください。
環境構築編から進めてくださっている方は、設定済みだとは思いますが、Reactアプリケーション起動時の簡略化です。

{
  "name": "form",
  <省略>
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
  },
  <省略>
}
# フロントエンド側(React)プロジェクトへ移動
$ cd login-form/form

# nodeを有効化して実行
$ nvm use
$ npm start


---
Compiled successfully!

You can now view form in the browser.

  Local:            http://localhost:3000
  On Your Network:  http://192.168.1.1:3000

Note that the development build is not optimized.
To create a production build, use npm run build.

webpack compiled successfully
Files successfully emitted, waiting for typecheck results...
Issues checking in progress...
No issues found.

□ バックエンド側(Node.js)

package.json内に以下の設定があることを今一度ご確認ください。
環境構築編から進めてくださっている方は、設定済みだとは思いますが、Node.jsアプリケーション起動時の簡略化です。

{
  "name": "server",
  <省略>
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "start": "npx ts-node app.ts"
  },
  <省略>
# バックエンド側(Node.js)プロジェクトへ移動
$ cd login-form/server

# nodeを有効化して実行
$ nvm use
$ npm start


---
> server@1.0.0 start
> npx ts-node app.ts

Server running on port 5000
データベースに接続に成功しました

■ 最後に


では、起動したログインフォームにユーザー情報を入力してログインボタンを押下してみてください。

本当は、ログインに成功したら違う画面に遷移するところまで実装すれば良かったんですが、私が力尽きています。
なので、時間が取れる時に改良して追記しておきますので、今はこれで我慢してください、、
ログイン認証に成功したら、バックエンド側(Node.js)のログが出ます。


ここまで本当にお疲れ様でした。
私も大変疲れました。
学んだことは、記事にしている時に改めて記憶に定着しているなぁと実感します。

良い、Webアプリケーションライフを!!

comment 📝

タイトルとURLをコピーしました