Skip to content

ReactとNestでWebアプリケーションを作る6回目です。 最初からやりたい場合はナビゲーションから初回を開いてください。

今回は登録されたアンケートを一覧で確認する機能を作っていきます。

Reactでログイン管理(OAuth2)#

Webアプリケーションでは、限られた人のみ、アプリケーションを使わせたいときがあります。

ここでは、特定のGoogle Accountを持っている人のみがアプリケーションを使えるようにしていきます。

設定#

Google Accountを持っているかどうかを確認するためにはOAuth2という仕組みを使うことが出来ます。 OAuth2を使うことで、アプリケーション自体でアカウントを管理しなくても、ログイン機能をアプリケーションに組み込むことができます。 GoogleでOAuth2を作るためには、Googleにアプリケーションを登録する必要があります。

https://console.cloud.google.com/apis/ に移動して

Google Cloud Platform

「新しいプロジェクト」を押します。 Google Cloud

任意の「プロジェクト名」を入力して「作成」を押します。 Google Cloud 「APIとサービス」→「認証情報」を押して Google Cloud 「認証情報を作成」を選択します。 Google Cloud 「OAuth クライアント ID」を選択します。 Google Cloud アプリケーションの種類に「ウェブ アプリケーション」を選択します。 Google Cloud 下の方にスクロールして、「認証済みのリダイレクトURI」に http://localhost:5173/oauthRedirect/google を追加します。ほかは変更しなくて良いです。 そして、作成ボタンを押します。 Google Cloud 「クライアント ID」 と 「クライアントシークレット」が生成されるのでメモしておきます。 「クライアント ID」 と 「クライアントシークレット」は他の人にばれないようにしてください。 Google Cloud

これでGoogle AccountでOAuthを使用する準備が出来ました。 続きまして、アプリケーションでOAuth2を使えるようにしていきます。

OAuth2実装#

OAuth2を以下の要領で実装します。 1. ブラウザでログインリンクを押すと、Reactの処理でサーバのControllerからGoogleのURLを取得し、Googleに画面遷移する。 2. Googleでログインすると、こちら側のブラウザアプリに遷移する。ブラウザアプリがサーバアプリに対してログインが正しいことを確認するように依頼する。 5. サーバアプリがGoogleにログインが正しいことを確認する。 6. Reactの処理でログイン後のページへ遷移する。

まずは、Reactで画面を作成し、Googleに遷移できるようにする。 myreactapp/front/src/Login.tsx というファイルを以下の内容で作成します。

export default function LoginPage() {
    async function moveToGoogleOAuth() {
    const response = await fetch("/api/login/google/loginPath?browserHost=" + window.location.host);
    window.location.href = (await response.json()).url;
  }
  return (
    <div>
      <a href="" onClick={moveToGoogleOAuth}>Google でログイン</a>
    </div >
  );
}

作ったページをURLと紐付けます。 myreactapp/front/src/main.tsx を変更します。

import React from 'react'
import ReactDOM from 'react-dom/client';
import { Route, createBrowserRouter, RouterProvider, createRoutesFromElements } from "react-router-dom";
import App from './App.tsx'
import './index.css'
import EnquetePage from './Enquete.tsx';
import LoginPage from './Login';//この行を追加

const router = createBrowserRouter(
  createRoutesFromElements(
    <>
      <Route path="/" element={<App />} />
      <Route path="/enquete/new" element={<EnquetePage />} />
        <Route path="/login" element={<LoginPage />} /> {/*この行を追加*/}
    </>
  )
);

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <RouterProvider router={router} />
  </React.StrictMode>,
)

サーバでGoogleのリンクを作成するようにします。

myreactapp/server/src/login.controller.ts を作成します。 このコントローラでGoogleのURLを生成し、ブラウザに渡します。

import { Controller, Res, Req, Get } from '@nestjs/common';
import { Request, Response } from "express";
const querystring = require('querystring');

const client_id = '{Google からコピーしたクライアントID}'
const client_secret = '{Google からコピーしたクライアントシークレット}'
const redirect_uri_postfix = '/oauthRedirect/google'
const response_type = 'code'
const scope = 'email'

const GOOGLE_URL_AUTH = 'https://accounts.google.com/o/oauth2/v2/auth'
const GOOGLE_URL_TOKEN = 'https://www.googleapis.com/oauth2/v4/token'
const GOOGLE_URL_USERINFO = 'https://www.googleapis.com/oauth2/v3/userinfo'

@Controller("/api/login")
export class LoginController {
  @Get("/google/loginPath")
  async loadGoogleLoginPath(@Req() req: Request) {
    var redirect_uri = req.protocol + "://" + req.query["browserHost"] + redirect_uri_postfix;

    const params = querystring.stringify({
        client_id,
        redirect_uri,
        response_type,
        scope,
    })
    return { url: `${GOOGLE_URL_AUTH}?${params}` };
  }
}

作ったcontrollerを公開します。 myreactapp/server/src/app.modules.tsを以下の内容に変更します。

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { EnqueteController } from './enquete.controller';
import { LoginController } from './login.controller'; // この行を追加

@Module({
  imports: [],
  controllers: [AppController, EnqueteController, LoginController], //この行に ,LoingControllerを追加
  providers: [AppService],
})
export class AppModule {}

そして、Google から戻される画面を用意します。 画面を作る準備として、画面で使う、ユーザ情報を保持するデータ構造を作成します。 myreactapp/front/src/context/UserContext.ts というファイルを以下の内容で作成します。

import { createContext, useContext, useState, ReactNode } from 'react';

export type User = {
  email: string;
  provider: string;
  auth_token: string;
};

type UserContextType = {
  loginUser: User | null;
  setLoginUser: (user: User | null) => void;
};

const UserContext = createContext<UserContextType | undefined>(undefined);

export function UserProvider({ children }: { children: ReactNode }) {
  const [loginUser, setLoginUser] = useState<User | null>(null);

  return (
    <UserContext.Provider value={{ loginUser, setLoginUser }}>
      {children}
    </UserContext.Provider>
  );
}

export function useUser() {
  const context = useContext(UserContext);
  if (context === undefined) {
    throw new Error('useUser must be used within a UserProvider');
  }
  return context;
}

myreactapp/front/src/RedirectedFromGoogle.tsx というファイルを以下の内容で作成します。

import { useEffect, useState } from "react";
import { useSearchParams, useNavigate } from "react-router-dom";
import { useUser } from "./context/UserContext";

export default function RedirectedFromGoogle() {
  const [message, setMessage] = useState("ログイン確認中。。。。");
  const navigate = useNavigate();
  const [searchParams] = useSearchParams();
  const code = searchParams.get("code");
  const redirect_uri = window.location.href.split("?")[0];
  const [loginComplete, setLoginComplete] = useState(false);
  const { setLoginUser } = useUser();

  useEffect(() => {
    (async () => {
      const response = await fetch("/api/login/google/authToken", { 
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({ code, redirect_uri })
      })
      const authInfo = await response.json()
      if(authInfo.access_token){
        setLoginUser({ email: authInfo.userInfo.email, provider: "google", auth_token: authInfo.access_token });
        setLoginComplete(true);
        setMessage("ログイン確認成功");
        return;
      }
      setMessage("ログインに失敗しました。")
      setTimeout(() => { navigate("/login") }, 3000);
    })();
  }, [code, redirect_uri, setLoginUser, navigate]);

  useEffect(() => {
    if (loginComplete) {
      navigate("/enquete/list");
    }
  }, [loginComplete, navigate]);

  return (
    <div>
      {message}
    </div >
  );
}

このページををURLに紐付けます。 myreactapp/front/src/main.tsx を変更します。

import React from 'react';
import ReactDOM from 'react-dom/client';
import { Route, createBrowserRouter, RouterProvider, createRoutesFromElements } from "react-router-dom";
import App from './App.tsx'
import './index.css'
import EnquetePage from './Enquete.tsx';
import LoginPage from './Login';
import RedirectedFromGooglePage from './RedirectedFromGoogle';//この行を追加します
import { UserProvider } from './context/UserContext'//この行を追加します


function Root() {  //この行を追加します
const router = createBrowserRouter(
  createRoutesFromElements(
    <>
      <Route path="/" element={<App />} />
      <Route path="/enquete/new" element={<EnquetePage />} />
      <Route path="/login" element={<LoginPage />} />
      <Route path="/oauthRedirect/google" element={<RedirectedFromGooglePage />} /> {/*この行を追加します */}

    </>
  )
);
return <React.StrictMode>
        <UserProvider>{/*この行を追加します */}
          <RouterProvider router={router} />
        </UserProvider>{/*この行を追加します */}
       </React.StrictMode>
}//この行を追加します

ReactDOM.createRoot(document.getElementById('root')!).render(
  //ここから
 // <React.StrictMode>
 //   <RouterProvider router={router} />
 // </React.StrictMode>
  //ここまでコメントアウト
  <Root /> // この行を追加します
)

myreactapp/server/src/login.controller.ts を編集し、Googleからauth_tokenを取得できるようにします。

import { Controller, Res, Req, Get, BadRequestException } from '@nestjs/common';//, BadRequestExceptionを追加します。
import { Request, Response } from "express";
const querystring = require('querystring');

const client_id = '{Google からコピーしたクライアントID}'
const client_secret = '{Google からコピーしたクライアントシークレット}'
const redirect_uri_postfix = '/oauthRedirect/google'
const response_type = 'code'
const scope = 'email'

const GOOGLE_URL_AUTH = 'https://accounts.google.com/o/oauth2/v2/auth'
const GOOGLE_URL_TOKEN = 'https://www.googleapis.com/oauth2/v4/token'
const GOOGLE_URL_USERINFO = 'https://www.googleapis.com/oauth2/v3/userinfo'

@Controller("/api/login")
export class LoginController {
  @Get("/google/loginPath")
  async loadGoogleLoginPath(@Req() req: Request) {
    var redirect_uri = req.protocol + "://" + (req.headers["x-forwarded-host"] || req.get("Host")) + redirect_uri_postfix;

    const params = querystring.stringify({
        client_id,
        redirect_uri,
        response_type,
        scope,
    })
    return { url: `${GOOGLE_URL_AUTH}?${params}` };
  }
  // ここから追加
  @Post('/google/authToken')
  async loadGoogleOauthToken(@Req() req: Request) {
    try {
        var params = new URLSearchParams();
        params.append('client_id', client_id);
        params.append('client_secret', client_secret);
        params.append('code', req.body.code);
        params.append('grant_type', 'authorization_code');
        params.append('redirect_uri', req.body.redirect_uri);

        const response = await fetch(GOOGLE_URL_TOKEN, {
            method: "POST",
            headers: {'Content-Type': 'application/x-www-form-urlencoded'},
            body: params.toString()
        })
        const responseJSON = await response.json();
        const access_token = responseJSON.access_token;

        const userInfoResponse = await fetch(GOOGLE_URL_USERINFO, {
            headers: {
                'Authorization': `Bearer ${access_token}`,
            }
        });
        const userInfo = await userInfoResponse.json();
        return {
            access_token,
            userInfo
        };
    } catch (e) {
        throw new BadRequestException();
    }
  }// ここまで追加
}

次に、ログインに成功したあとに表示する、アンケートの一覧ページを作成します。

myreactapp/front/src/ に EnqueteList.tsx というファイルを作成してください。

import { useEffect, useState } from "react";
import { useUser } from './context/UserContext';

export default function EnqueteListPage() {
  const { loginUser } = useUser();
  if (!loginUser) {
    return <div>ログインが必要です 🔒</div>;
  }

  const [enqueteElements, setEnqueteElements] = useState(<div>取得中。。。。</div>);
  useEffect(() => {
    (async () => {
      const response = await fetch("/api/enquetes", { 
        headers: {'Authorization': loginUser.auth_token!}
      })
      const enquetes = await response.json();
        const enqueteElements = enquetes.map((enquete: any) => {
          return <tr>
            <td>{enquete.mail}</td>
            <td>{enquete.point}</td>
            <td>{enquete.message}</td>
          </tr>
      })
      setEnqueteElements(<table>
        <thead><td>メールアドレス</td><td>ポイント</td><td>メッセージ</td></thead>
        <tbody>{enqueteElements}</tbody>
      </table>);
    })();
  },[])
  return (
    <div>
        <div>{loginUser?.email}</div>
        <div>アンケート一覧</div>
        <div>{enqueteElements}</div>
    </div >
  );
}

アンケート一覧ページをURLに紐付けます。 myreactapp/front/src/main.tsx を変更します。

import React from 'react';
import ReactDOM from 'react-dom/client';
import { Route, createBrowserRouter, RouterProvider, createRoutesFromElements } from "react-router-dom";
import App from './App.tsx'
import './index.css'
import EnquetePage from './Enquete.tsx';
import LoginPage from './Login';
import RedirectedFromGooglePage from './RedirectedFromGoogle';
import { UserProvider } from './context/UserContext'
import EnqueteListPage from './EnqueteList';//この行を追加します


function Root() {
const router = createBrowserRouter(
  createRoutesFromElements(
    <>
      <Route path="/" element={<App />} />
      <Route path="/enquete/new" element={<EnquetePage />} />
      <Route path="/login" element={<LoginPage />} />
      <Route path="/oauthRedirect/google" element={<RedirectedFromGooglePage />} />
      <Route path="/enquete/list" element={<EnqueteListPage />} /> {/*この行を追加します */}
    </>
  )
);
return <React.StrictMode>
        <UserProvider>{/*この行を追加します */}
          <RouterProvider router={router} />
        </UserProvider>{/*この行を追加します */}
       </React.StrictMode>
}//この行を追加します

ReactDOM.createRoot(document.getElementById('root')!).render(
  //ここから
 // <React.StrictMode>
 //   <RouterProvider router={router} />
 // </React.StrictMode>
  //ここまでコメントアウト
  <Root /> // この行を追加します
)


import React from 'react';
import ReactDOM from 'react-dom/client';
import { Route, createBrowserRouter, RouterProvider, createRoutesFromElements } from "react-router-dom";
import App from './App.tsx'
import './index.css'
import EnquetePage from './Enquete.tsx';
import LoginPage from './Login';
import RedirectedFromGooglePage from './RedirectedFromGoogle';
import { User } from './context/UserContext';
import EnqueteListPage from './EnqueteList';//この行を追加します

function Root() {

const [loginUser, setLoginUser] = React.useState<User | null>(null);
const router = createBrowserRouter(
  createRoutesFromElements(
    <>
      <Route path="/" element={<App />} />
      <Route path="/enquete/new" element={<EnquetePage />} />
      <Route path="/login" element={<LoginPage />} />
      <Route path="/oauthRedirect/google" element={<RedirectedFromGooglePage setLoginUser={setLoginUser} />} />
      <Route path="/enquete/list" element={<EnqueteListPage loginUser={loginUser!} />} /> {/*この行を追加します*/}
    </>
  )
);
return <React.StrictMode>
        <RouterProvider router={router} />
       </React.StrictMode>
}

ReactDOM.createRoot(document.getElementById('root')!).render(
  <Root />
)

最後に、サーバサイドでログイン状態をチェックして、アンケート一覧を返却するページを作成します。 myreactapp/server/src/enquete.controller.ts を編集します。

import { Controller, Post, Body, Res, HttpStatus, Req, Get, BadRequestException } from '@nestjs/common';
import { Request, Response } from "express"; //この行にRequestを追加
import { getDataSource } from './database'; 
import Enquete from './models/Enquete';

const GOOGLE_URL_USERINFO = 'https://www.googleapis.com/oauth2/v3/userinfo'
const ADMIN_MAIL_ADDRESS = '一覧を見ることができるメールアドレス'

class EnqueteDto {
    public point: number = 0
    public message: string = ""
    public mail: string = ""
}

@Controller("/api/enquetes")
export class EnqueteController {
  @Post()
  async postEnquete(@Body() enqueteDto: EnqueteDto) {
    if(!enqueteDto.point || !enqueteDto.message || !enqueteDto.mail) {
      throw new BadRequestException();
    }
    const enquete = new Enquete();
    enquete.point = enqueteDto.point;
    enquete.message = enqueteDto.message;
    enquete.mail = enqueteDto.mail;
    await getDataSource().getRepository(Enquete).save(enquete);
    return enquete;
  }
  @Get()
  async loadEnquetes(@Req() req: Request) {
    const auth_token = req.get("Authorization");
    const userInfoResponse = await fetch(GOOGLE_URL_USERINFO, {
      headers: {
          'Authorization': `Bearer ${auth_token}`,
      }
    });
    const userInfo = await userInfoResponse.json();
    if(userInfo.email == ADMIN_MAIL_ADDRESS) {
      return await getDataSource().getRepository(Enquete).find();
    } else {
      throw new BadRequestException();
    }
  }
}

コード中の 一覧を見ることができるメールアドレス はGoogleにログインするときのメールアドレスに書き換えてください。

これで、アプリが完成しました!

http://localhost:3000/login から「Google でログイン」を押して、Googleにログインすると、一覧が見れるようになっています。

これで、GoogleのOAuth認証で表示に制限をかけつつ、一覧を表示することが出来ました。