独学エンジニアのメモ帳

得た知識のアウトプットとか日常のメモとか。ゆるくやる。

Laravel8 + Sanctum + ReactでSPA認証


前提

Larave8 + ReactでSPA環境ができていること

この記事ではブログ内で作った続きでやります。 nochio12.hatenablog.com


基本的に公式通り進めます。
laravel.com

Sanctumではトークンでの認証とCookieを使った認証のどちらかを選ぶことができますが、
今回はCookieを使った認証を実装します。

インストール

$ composer require laravel/sanctum
$ php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
$ php artisan migrate

2つめのコマンドで設定ファイルとmigrationが作成されます。

ミドルウェアの追加

[Kernel.php]

+ use Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful;

'api' => [
+    EnsureFrontendRequestsAreStateful::class,
    'throttle:60,1',
    \Illuminate\Routing\Middleware\SubstituteBindings::class,
],

モデルの修正

[Models/User.php]

+ use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable
{
-   use HasApiTokens, Notifiable;
+   use HasFactory, HasApiTokens, Notifiable;
}

CORS対策

[config/cors.php]

- 'paths' => ['api/*],
+ 'paths' => ['api/*', 'sanctum/csrf-cookie'],

- 'supports_credentials' => false,
+ 'supports_credentials' => true,



実際の環境のドメインをenvに追記
[.env]

+ SANCTUM_STATEFUL_DOMAINS=127.0.0.1:8080



[config/sanctum.php]

    'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', 'localhost,127.0.0.1,127.0.0.1:8000,::1')),

ローカルでの開発で、ここにあるドメインであれば追記しなくて平気ですが
ポートを変えていたり、本番環境ではどっちみち必要。 調べると出てきますが、公式で言及がないため忘れそう。

認証処理

Api用の認証コントローラを作成

$ php artisan make:controller Api/Auth/LoginController

[Api/Auth/LoginController.php]

<?php

namespace App\Http\Controllers\Api\Auth;

use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

class LoginController extends Controller
{
    use AuthenticatesUsers;

    /**
     * ログイン
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response|\Illuminate\Http\JsonResponse
     *
     * @throws \Illuminate\Validation\ValidationException
     */
    public function login(Request $request)
    {
        // バリデーション
        $this->validateLogin($request);
        $result = false;
        $status = 401;
        $message = 'ユーザが見つかりません';
        $user = null;
        $credentials = $request->only('email', 'password');
        if (Auth::attempt($credentials)) {
            // Success
            $result = true;
            $status = 200;
            $message = 'OK';
            $user = Auth::user();
            // ※古いトークン削除&新しいトークン生成
            $user->tokens()->where('name', 'token-name')->delete();
            $token = $user->createToken('token-name')->plainTextToken;
        }
        return response()->json(['result' => $result, 'status' => $status, 'user' => $user, 'message' => $message]);
    }


    /**
     * ログアウト
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response|\Illuminate\Http\JsonResponse
     */
    public function logout(Request $request)
    {
        Auth::logout();
        $result = true;
        $status = 200;
        $message = 'ログアウトしました';
        return response()->json(['result' => $result, 'status' => $status, 'message' => $message]);
    }
}

フロントの実装

[js/bootstrap.js]

window.axios = require('axios');

window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
+ window.axios.defaults.withCredentials = true;


ルーティングの追加 [app.js]

require('./bootstrap');

import React from "react";
import ReactDOM from "react-dom";
import { BrowserRouter, Route } from "react-router-dom";

import NavBar from "./NavBar";
import Home from "./Home";
import About from "./About";
import Auth from "./Auth"; // 追加

const App = () => {
    return (
        <BrowserRouter>
            <NavBar />
            <Route path="/about" component={About} />
            <Route path="/auth" component={Auth} /> // 追加
            <Route exact path="/" component={Home} />
        </BrowserRouter>
    );
};

if (document.getElementById("app")) {
    ReactDOM.render(<App />, document.getElementById("app"));
}


ログイン画面
[Auth.js]

import React, { useState, useEffect } from "react";

const Auth = () => {
  const [user, setUser] = useState(null);
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");

  // ブラウザリロード時にログイン済みか判定
  useEffect(() => {
    getUser();
  }, [getUser]);

  // 認証ユーザを取得
  const getUser = () => {
      axios.get("/api/user").then(res => {
        console.log('[getUser]ログイン済み');
        console.log(res.data);
        setUser(res.data);
      }).catch(err => {
        console.log('[getUser]ログインしてません');
      })
  };

  // ログイン
  const login = async e => {
    e.preventDefault();
    // ログイン時にCSRFトークンを初期化
    axios.get("/sanctum/csrf-cookie").then(response => {
      axios
      .post("/api/login", {
        email,
        password
      })
      .then(res => {
        console.log(res.data);
        if (res.data.result) {
          console.log('[login]ログイン成功');
          setUser(res.data.user);
        } else {
          console.log(res.data.message);
          console.log('[login]ログイン失敗');
        }
      })
      .catch(err => {
        console.log(err.response);
        console.log('[login]ログイン失敗');
      });
    })

  };

  // ログアウト
  const logout = () => {
    axios
      .get("/api/logout")
      .then(res => {
        setUser(null);
      })
      .catch(err => {
        console.log(err);
      });
  };

  // ログインフォーム
  let form = (
    <form onSubmit={login}>
      <label>email</label>
      <input
        type="email"
        value={email}
        onChange={e => setEmail(e.target.value)}
      />
      <label>password</label>
      <input
        type="password"
        value={password}
        onChange={e => setPassword(e.target.value)}
      />
      <button type="submit">Login</button>
    </form>
  );

  // ユーザ情報
  let userInfo = null;

  // 認証済みの場合、ログアウトボタンとユーザ情報を表示
  if (user) {
    form = <button onClick={logout}>Logout</button>;
    userInfo = (
      <div>
        <h2>User</h2>
        <div>name: {user.name}</div>
        <div>email: {user.email}</div>

      </div>
    );
  }

  return (
    <div>
      {form}
      {userInfo}
      <button onClick={getUser}>getUser</button>
    </div>
  );
};

export default Auth;

ビルドして確認。

ログインを試す

ログインのためのユーザーを作成します。 1つあればいいので、tinkerでサクッと作ります。

$ php artisan tinker
>>> App\Models\User::factory()->create(['name' => 'hoge', 'email' => 'hoge@hoge.com', 'password' => Hash::make('password')]);

先ほど作成したフォームに
email : 'hoge@hoge.com
password : password
を打ち込み、ログインを試します。

ログインが成功し、画面にユーザー情報が表示されれば成功。 ログアウトせずにリロードをかけると勝手にログイン状態になるはず。

f:id:nochio12:20201003215237p:plain

まとめ

パッケージがよしなにやってくれてラクチンですが
内部的に何が起きているのかはちゃんと理解して使いたいところ。