Laravel Sail Sanctum

2025-04-14

パクリ36発目!!
まだ少しやる気が続いているのでそのまま続ける!

パクリ元

内容

前回作った環境 (Laravel sail + react) でSPA認証ログインまでをやりたい。

Sanctum

パクリ元1つ目を参考に Laravel Sanctum に対応する。

ドメイン設定

laravel sail としては localhost があればいいのでそのまま。

Middleware設定

bootstrap/app.php
・・・
    ->withMiddleware(function (Middleware $middleware) {
        $middleware->statefulApi();
    })
・・・

CORS設定

laravel sail なので無視。

ユーザー作成

tinker とやらのコマンドラインで作成する。

~/example-app$ sail artisan tinker
Psy Shell v0.12.8 (PHP 8.4.5 — cli) by Justin Hileman
> use App\Models\User;
> User::factory(3)->create();
= Illuminate\Database\Eloquent\Collection {#5248
・・・
> q

phpmyadmin(localhost:8888)でユーザー3件登録を確認。

コントローラー作成

全パクリ。

~/example-app$ sail artisan make:controller Api/BaseController
app/Http/Controllers/Api/BaseController.php
<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Database\Eloquent\Collection;

class BaseController extends Controller
{
    /**
     * success response method
     * 
     * @param   string                  $message
     * @param   array<string, mixed>|Collection    $data
     * @return  \Illuminate\Http\JsonResponse
     */
    public function sendResponse(string $message, array|Collection $data)
    {
        $response = [
            'success' => true,
            'message' => $message,
            'data'    => $data,
        ];

        return response()->json($response, 200);
    }

    /**
     * return error response.
     * 
     * @param   string  $message
     * @param   MessageBag|array<int|string, mixed> $errorMessages = []
     * @param   int $code = 404
     * @return  \Illuminate\Http\JsonResponse
     */
    public function sendError(
        string $message,
        MessageBag|array $data = [],
        int $code = 404
    ) {
        $response = [
            'success' => false,
            'message' => $message,
        ];

        if (!empty($data)) {
            $response['data'] = $data;
        }

        return response()->json($response, $code);
    }
}
~/example-app$ sail artisan make:controller Api/LoginController
app/Http/Controllers/Api/LoginController.php
<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Api\BaseController;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Validator;

class LoginController extends BaseController
{
    /**
     * returns default response.
     * route: get('/api/login')
     *
     * @return  \Illuminate\Http\JsonResponse
     */
    public function create()
    {
        return $this->sendError(
            'Authentication Required.',
            [],
            401,
        );
    }

    /**
     * authenticate with credentials.
     * route: post('/api/login')
     *
     * @param   Request $request
     * @return  \Illuminate\Http\JsonResponse
     */
    public function login(Request $request)
    {
        $validator = Validator::make($request->all(), [
            'email' => ['required', 'email'],
            'password' => ['required'],
        ]);
        if ($validator->fails()) {
            return $this->sendError(
                'Bad Request',
                [$validator->errors()],
                400,
            );
        }

        $credentials = $request->only(['email', 'password']);
        if (Auth::attempt($credentials)) {
            $request->session()->regenerate();
            return redirect()->intended(route('api.loggedin'));
        }

        return $this->sendError(
            'Login Failed',
            ['email' => 'The provided credentials do not match our records.'],
            401,
        );
    }

    /**
     * returns response after after login redirect.
     * route: get('/api/loggedin')
     *
     * @return  \Illuminate\Http\JsonResponse
     */
    public function loggedin()
    {
        return $this->sendResponse(
            'Logged in.',
            ['email' => 'Authenticated.'],
        );
    }
}

なんかバリデーション箇所うまくいってなかったので修正。

~/example-app$ sail artisan make:controller Api/UsersController
app/Http/Controllers/Api/UsersController.php
<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Api\BaseController;
use App\Models\User;
use Illuminate\Http\Request;

class UsersController extends BaseController
{
    /**
     * returns list of users.
     *
     * @return  \Illuminate\Http\JsonResponse
     */
    public function index()
    {
        $users = User::all();
        return $this->sendResponse('successfully fetched.', $users);
    }
}
~/example-app$ sail artisan make:controller SpaController
app/Http/Controllers/SpaController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

class SpaController extends Controller
{
    /**
     * returns spa.
     *
     * @return  \Illuminate\Http\Response
     */
    public function index()
    {
        return view('spa.index');
    }
}

ビュー作成

よく見たら Vue.js を使用している様子。。パクリ元2つ目も参考に、以前やった react のログインに適当に置き換えてみる。

~/example-app$ sail artisan make:view spa/index
resources/views/spa/index.blade.php
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>SPA | Stateful Authentication with Sanctum</title>

        {{-- react に変更があったとき自動で --}}
        @viteReactRefresh
        @vite(['resources/sass/app.scss', 'resources/ts/index.tsx'])
    </head>
    <body>
        <div id="root"></div>
    </body>
</html>

index.tsx、Login.tsx、Dashboard.tsx を作成。

resources/ts/index.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { Route, Routes, BrowserRouter } from "react-router-dom";
import { Login } from "./Login";
import { Dashboard } from "./Dashboard";

createRoot(document.getElementById('root') as Element).render(
  <StrictMode>
    <BrowserRouter>
      <Routes>
        <Route path="/login" element={<Login />} />
        <Route path="/dashboard" element={<Dashboard />} />
      </Routes>
    </BrowserRouter>
  </StrictMode>
);
~/example-app$ sail npm install -D @mui/material @emotion/react @emotion/styled
resources/ts/Login.tsx
import {
    Box,
    Button,
    Card,
    CardActions,
    CardContent,
    CardHeader,
    TextField,
 } from "@mui/material";
import { useState } from 'react';
import { useNavigate } from "react-router-dom";
import axios from 'axios';

export const Login = () => {
    const [email, setEmail] = useState("");
    const [password, setPassword] = useState("");
    const navigate = useNavigate();

    const cardStyle = {
        display: "block",
        transitionDuration: "0.3s",
        height: "450px",
        width: "400px",
        variant: "outlined",
    };
    const fetchUsers = async () => {
        http.get('/api/users').then((res2) => {
            console.log(res2.data.data);
        }).catch((err) => {
            console.log("ユーザー取得に失敗しました。")
        })
    }
    const http = axios.create({
        baseURL: 'http://localhost',
        withCredentials: true,
    });
    const onClickLogin = () => {
        const auth = async () => {
            http.get('/sanctum/csrf-cookie').then((res1) => {
                http.post('/api/login', {email, password}).then((res2) => {
                    if (res2.status == 200) {
                        console.log("ログインに成功しました。");

                        navigate("/dashboard");
                    } else {
                        console.log("ログインに失敗しました。");
                    }
                }).catch((err) => {
                    console.log("ログインに失敗しました。")
                })
            }).catch((err) => {
                console.log("ログインに失敗しました。")
            })
        }
        auth();    
    };

    return (
        <Box
            display="flex"
            alignItems="center"
            justifyContent="center"
            padding={20}
        >
            <Card style={cardStyle}>
                <CardHeader title="ログインページ" />
                <CardContent>
                    <div>
                        <TextField
                            fullWidth
                            id="username"
                            type="email"
                            label="Username"
                            placeholder="Username"
                            margin="normal"
                            onChange={(e) => setEmail(e.target.value)}
                        />
                        <TextField
                            fullWidth
                            id="password"
                            type="password"
                            label="Password"
                            placeholder="Password"
                            margin="normal"
                            onChange={(e) => setPassword(e.target.value)}
                        />
                    </div>
                </CardContent>
                <CardActions>
                    <Button
                        variant="contained"
                        size="large"
                        color="secondary"
                        onClick={onClickLogin}
                    >
                    Login
                    </Button>
                </CardActions>
            </Card>
        </Box>
    );
};
resources/ts/Dashboard.tsx
import {
    Box,
    Card,
    CardContent,
    CardHeader,
 } from "@mui/material";
import { useState, useEffect } from 'react';
import axios from 'axios';

type User = {
    id: string;
    name: string;
};
const cardStyle = {
    display: "block",
    transitionDuration: "0.3s",
    height: "450px",
    width: "400px",
    variant: "outlined",
};
export const Dashboard = () => {
    const [users, setUsers] = useState<User[]>([]);
    const http = axios.create({
        baseURL: 'http://localhost',
        withCredentials: true,
    });
    const fetchUsers = async () => {
        http.get('/api/users').then((res2) => {
            setUsers(res2.data.data);
        }).catch((err) => {
            console.log("ユーザー取得に失敗しました。")
        })
    }
    useEffect(() =>{
        fetchUsers();
    },[]);

    return (
        <Box
            display="flex"
            alignItems="center"
            justifyContent="center"
            padding={20}
        >
            <Card style={cardStyle}>
                <CardHeader title="User Name" />
                <CardContent>
                {users.map(user => (
                    <div key={user.id}>{user.name}</div>
                ))}
                </CardContent>
            </Card>
        </Box>
    );
};

いつの間にかタブ数がおかしくなってる気もするが、、

ルーティング

routes/api.php
<?php

use App\Http\Controllers\Api\LoginController;
use App\Http\Controllers\Api\UsersController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::get('/user', function (Request $request) {
    return $request->user();
})->middleware('auth:sanctum');

Route::get('/login', [LoginController::class, 'create'])->name('login');
Route::post('/login', [LoginController::class, 'login'])->name('api.login.store');
Route::middleware('auth:sanctum')->group(function () {
    Route::get('/loggedin', [LoginController::class, 'loggedin'])->name('api.loggedin');
    Route::get('/users', [UsersController::class, 'index'])->name('api.users');
});
routes/web.php
<?php

use App\Http\Controllers\SpaController;
use Illuminate\Support\Facades\Route;

Route::get('/login', [SpaController::class, 'index'])->name('spa');

確認

http://localhost/api/login 、または、http://localhost/api/users にブラウザでアクセスして、{“success":false,"message":"Authentication Required."} が表示されることを確認。

http://localhost/login から users テーブルに入っているデータの email カラムの値と password という値でログインボタン押下してユーザー一覧画面が表示されることを確認。

とりあえずOK。

はじめ fetch でやってたけど XSRF-TOKEN が返ってきても自分でその値を取り出して X-XSRF-TOKEN をくっつけないといけなかったりして axios に変更したりで結構時間がかかる。

雑感

前回払い出したレスポンスクッキーを認証時にリクエストクッキーとして渡して認証するというのが Sanctum の機能ということを理解。

細かい所が違ってたりして動かないことが結構ある。情報が古いからなのか元からおかしかったのかよく分からんが AI の情報集めもどうしてるんだろと思う。英語情報だと正確なのかも知らんが周回遅れでやってると最新情報を集める必要がないのでどうしても日本語情報に頼っちゃうよねー