0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Laravel+React&PKCEのSPA環境構築メモ

Last updated at Posted at 2022-01-30

先日書いた Laravel+Vue&PKCEのSPA環境構築メモ

のReact版も作成してみました。
※ Laravel側は全く同じなので割愛し、見出しを合わせて差分のところだけ記載しています

https://github.com/pei-miyapei/laravel-react-spa
(こちらは簡単なAPIの実装とUIフレームワーク(Ant Design)を導入した状態です)

構成

API server コンテナ(SPA バックエンド、ユーザー管理)

(割愛)

SPA クライアントコンテナ(SPA フロントエンド)

  • Vite
  • React 17
  • TypeScript
  • react-router
  • js-pkce

React+Vite+TypeScript環境の構築

Vue版との違いは、react-tsのテンプレートを使用しているというだけです。
あとはおなじ。

bash
yarn
yarn create vite temp --template react-ts
vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react()],
  server: {
    host: true,
    port: 3000,
    // https: true,
  },
});

SPA側の実装

react-router, js-pkceをインストール

bash
yarn add react-router-dom@6 js-pkce

ルーティングの設定

client/src/routes/Router.tsx
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import { About } from '../views/About';
import { AuthorizationCallback } from '../views/Auth/AuthorizationCallback';
import { Home } from '../views/Home';

export const Router = () => {
  return (
    <BrowserRouter>
      <Routes>
        <Route path='/' element={<Home />} />
        <Route path='/about' element={<About />} />
      </Routes>
    </BrowserRouter>
  );
};

App.tsxのコンテンツはreact-routerの中身(<router>)のみにしておきます
※ Vue版とファイル構成も合わせているだけで、別にApp.tsxを使う必要はありません

client/src/App.tsx
import './App.css';
import { Router } from './routes/Router';

function App() {
  return <Router />;
}

export default App;
MasterPage.tsx(Layout)を追加しておく

未認証時に丸ごと非表示にしたいため、
別途レイアウト用のVueを作成してその中にコンテンツページを表示するようにしておきます。

Vue版ではLayout.vueになっています。
※ ant-designを入れた際にLayoutというコンポーネント名が被ったため…

client/src/views/MasterPage.tsx
import { Link, Outlet } from 'react-router-dom';

export const MasterPage = () => {
  return (
    <div id="nav">
      <Link to='/'>Home</Link> |
      <Link to='/about'>About</Link>
    </div>
    <Outlet />
  );
};

このコンポーネントの入れ子のルーティングは以下のように書けます

client/src/routes/Router.tsx
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import { About } from '../views/About';
import { Home } from '../views/Home';
import { MasterPage } from '../views/MasterPage';

export const Router = () => {
  return (
    <BrowserRouter>
      <Routes>
        <Route path='/' element={<MasterPage />}>
          <Route path='/' element={<Home />} />
          <Route path='/about' element={<About />} />
        </Route>
      </Routes>
    </BrowserRouter>
  );
};

ネストされたルートについての詳細はこちら
https://reactrouter.com/docs/en/v6/getting-started/tutorial#nested-routes

トークンを保持する入れ物

これに関してはCookieなどに保存する場合は不要です。
ここでは変数内に持っているだけという状態で実装するため、その入れ物です。
(タブを閉じたら消えます)

Vue版ではProvide/Injectという機能を使用しました。
Reactにも同様のContextという機能があり、これを使用します。

client/src/store/AuthContext.tsx
import { createContext, useContext, useState } from 'react';

class AuthTokens {
  constructor(public accessToken = '', public refreshToken = '') {}
}

// ProviderProps
const authProps = (
  tokens = new AuthTokens(),
  handleSetTokens = (accessToken = '', refreshToken = '') => {}
) => {
  const hasToken = () => tokens.accessToken !== '';

  return { tokens, handleSetTokens, hasToken };
};
export type AuthProps = ReturnType<typeof authProps>;

// Context
let AuthContext = createContext(authProps());

// Provider
export const AuthProvider = ({ children }: any) => {
  const [tokens, setTokens] = useState(new AuthTokens());
  const handleSetTokens = (accessToken = '', refreshToken = '') => {
    setTokens({ ...tokens, ...new AuthTokens(accessToken, refreshToken) });
  };
  const props = authProps(tokens, handleSetTokens);

  return <AuthContext.Provider value={props}>{children}</AuthContext.Provider>;
};

// Consumer
export const useAuthContext = () => useContext(AuthContext);

Vue版だと上記をプラグインの機能を使用してアプリ全体に適用しました。
ReactではProviderの子要素で使用することができるので、こちらも最上位から適用します

client/src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import { AuthProvider } from './store/AuthContext';    // ← 追記

ReactDOM.render(
  <React.StrictMode>
    <AuthProvider>    {/* ← 追記 */}
      <App />
    </AuthProvider>
  </React.StrictMode>,
  document.getElementById('root')
);

ページの作成

残りの

  • トークンチェック&認可リクエストするガワページ
  • 認可コードコールバック用ページ

を作成します。

トークンチェック&認可リクエストするガワページ
  • 認可が必要なページにかませて使用
  • トークンがなければ認可リクエストを発行(if ~)
  • トークンがなければデフォルトスロットの中身(コンテンツ)の描画を行わない(v-if)
  • トークンは先ほど作成した、プラグインでprovideされた入れ物を召喚して確認(injectAuth)

というコンポーネント

client/src/components/Auth/AuthGuard.tsx
import PKCE from 'js-pkce';
import { useAuthContext } from '../../store/AuthContext';

export const AuthGuard = ({ children }: any) => {
  const { hasToken } = useAuthContext();

  if (!hasToken()) {
    const pkce = new PKCE({
      client_id: '1', // `php artisan passport:client --public` したときのIDです
      redirect_uri: location.origin + '/auth/callback', // 戻ってくるURL
      authorization_endpoint: 'http://localhost/server/oauth/authorize', // Laravel側の認可エンドポイント
      requested_scopes: '*',
    });
    location.replace(pkce.authorizeUrl());
    return <></>;
  } else {
    return <>{children}</>;
  }
};

を作成。

認証が必要なページにこのコンポーネントを導入します。
今回はコンテンツページは全部認証が必要なページとして、
最初に作成したグローバルメニューを持ったMasterPage.tsxに仕込みます

client/src/views/MasterPage.tsx
import { Link, Outlet } from 'react-router-dom';
import { AuthGuard } from '../components/Auth/AuthGuard';

export const MasterPage = () => {
  return (
    <AuthGuard>
      <div id="nav">
        <Link to='/'>Home</Link> |
        <Link to='/about'>About</Link>
      </div>
      <Outlet />
    </AuthGuard>
  );
};
認可コードコールバック用ページ

Laravelで認証後戻ってくるページ。受け取った認可コードとトークンを交換します。
Laravel側で指定した /auth/callback のページになります。

js-pkceのexchangeForAccessTokenというメソッドでトークンを受け取れます。
それを用意した入れ物(injectAuth)に保存(auth.setToken)し、
ここでは再度トップ画面に戻ります

client/src/views/Auth/AuthorizationCallback.tsx
import PKCE from 'js-pkce';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuthContext } from '../../store/AuthContext';

export const AuthorizationCallback = () => {
  const pkce = new PKCE({
    client_id: '1',
    redirect_uri: location.origin + '/auth/callback',
    token_endpoint: 'http://localhost/server/oauth/token',
  });
  const { handleSetTokens } = useAuthContext();
  const navigate = useNavigate();

  useEffect(() => {
    pkce.exchangeForAccessToken(document.location.href).then((response) => {
      handleSetTokens(response.access_token, response.refresh_token);
      // 認証後に遷移するページへ
      navigate('/', { replace: true });
    });
  }, []);

  return <></>;
};

このページを /auth/callback のURLで公開します

client/src/routes/Router.tsx
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import { About } from '../views/About';
import { AuthorizationCallback } from '../views/Auth/AuthorizationCallback'; // ← 追加
import { Home } from '../views/Home';
import { MasterPage } from '../views/MasterPage';

export const Router = () => {
  return (
    <BrowserRouter>
      <Routes>
        <Route path='/auth/callback' element={<AuthorizationCallback />} /> {/* ← 追加  */}
        <Route path='/' element={<MasterPage />}>
          <Route path='/' element={<Home />} />
          <Route path='/about' element={<About />} />
        </Route>
      </Routes>
    </BrowserRouter>
  );
};

一応完了

というわけでとりあえずこれでVue版同様、
React側にアクセスするとトークンがないためLaravelに遷移、
認証・承認後トークンをゲットして、ページがみられるようになると思います。
laravel-react-spa-pkce.gif
(これはAntDesignなどが入ってるので見た目違うと思いますが…)

締め

Reactはまだ全然でして…
おかしな点などがありましたらこっそりご教示ください…orz

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?