Laravel11の公式パッケージSanctumを使って、SPA(Single Page Application)のAPI認証機能を実装していきます。
前提条件
- PHP8.2以降インストール済
- Composerインストール済
- DBはデフォルトのSQLiteを使います
- DB ClientはSQLite3 CLIを使います
- APIクライアントはVue3 + axiosを使います
- Vue3、axios どちらもCDN版を使います
- PostmanやApidog、Thunder Client等は使いません
これからやること
- Laravel11新規プロジェクト作成
- Sanctumインストール
- APIクライアントのドメイン設定
- Middleware設定
- CORS設定
- マイグレーション
- コントローラー作成
- ビュー作成
- ルーティング
- ビルトインサーバ起動
- ブラウザでの動作確認
Laravel11新規プロジェクト作成
新規プロジェクト「sanctum-app」を作成します。
composer create-project laravel/laravel:^11 sanctum-app
Sanctumインストール
プロジェクトフォルダに入ります。
cd sanctum-app/
Sanctumをインストールします。
php artisan install:api
途中でマイグレーションするか訊かれるので、「yes」か「no」を選択します。
[Enter]だけ押しても構いません。(=デフォルトのyes)
APIクライアントのドメイン設定
APIへのリクエストを許可するクライアントのドメインを設定します。
「config/sanctum.php」を開いて編集します。
デフォルトでは次のようになっています。
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
'%s%s',
'localhost,localhost:3000,localhost:8000,127.0.0.1,127.0.0.1:8000,::1',
Sanctum::currentApplicationUrlWithPort()
))),
ここの項目「stateful」の値として、「stateful認証」を利用する
APIクライアント(SPA)のドメイン名を、
「<ドメイン名>:<ポート番号>」の形式で、
カンマ区切りの文字列で列記します。
当記事の場合は開発環境の「localhost:8000」か「127.0.0.1:8000」となるので、デフォルトのままとします。
Middleware設定
「ステートフル認証」を利用するようにMiddlewareの設定をします。
「bootstrap/app.php」を開いて編集します。
デフォルトで次のようになっている箇所を
->withMiddleware(function (Middleware $middleware) {
//
})
次のように編集します。
->withMiddleware(function (Middleware $middleware) {
$middleware->statefulApi();
})
CORS設定
APIクライアントが、API側のサブドメインである場合は
CORS (Cross-Origin Resource Sharing)の設定が必用です。
※当記事のようにすべて「localhost」で完結する環境の場合は
※このステップは無視してください。
CORS設定ファイルを編集できるようにします。
php artisan config:publish cors
「config/cors.php」が生成されます。
項目「supports_credentials」の値を「true」にします。
SSRでaxiosを使う場合は、
「resources/js/bootstrap.js」に次の設定を追記します。
axios.defaults.withCredentials = true;
axios.defaults.withXSRFToken = true;
最後に、「config/session.php」を編集します。
例えば、APIが「api.example.com」、
SPAが「example.com」の場合、
項目「domain」の設定を「.example.com」とします。
※共通のルートドメインの先頭にドット「.」を付けます。
'domain' => '.example.com',
マイグレーション
ダミーのユーザーデータを作成するために、
「database/seeders/DatabaseSeeder.php」を編集します。
public function run(): void
{
User::factory(10)->create();
}
シードオプション付きでマイグレーションしなおします。
php artisan migrate:fresh --seed
コントローラー作成
4つのコントローラーを作成します。
- API用基底コントローラー
- ログインAPI用コントローラー
- ユーザー一覧取得API用コントローラー
- SPA用コントローラー
まずは基底コントローラーを作成します。
php 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);
}
}
ログインAPI用コントローラーを作成します。
php 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;
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)
{
$credentials = $request->validate([
'email' => ['required', 'email'],
'password' => ['required'],
]);
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.'],
);
}
}
ユーザー一覧取得API用コントローラーを作成します。
php 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);
}
}
SPA用コントローラーを作成します。
php 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');
}
}
ビュー作成
SPA用のビューを作成します。
php 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">
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<title>SPA | Stateful Authentication with Sanctum</title>
<script src="https://unpkg.com/vue-cookies@1.8.4/vue-cookies.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/axios@1.6.7/dist/axios.min.js"></script>
</head>
<body>
<div id="app">
<ul class="mx-4 mt-4 px-4 py-4 border border-gray-600 rounded-md">
<li>
<label class="pr-2">email</label>
<input type="text" v-model="email" class="border border-gray-500 rounded-sm hover:bg-yellow-200" />
<span
v-if="validation.email.length > 0"
class="ml-4 px-4 py-2 bg-red-200 text-red-600 text-sm"
>
@{{ validation.email }}
</span>
</li>
<li class="mt-4">
<label class="pr-2">password</label>
<input type="password" v-model="password" class="border border-gray-500 rounded-sm hover:bg-yellow-200" />
<span
v-if="validation.password.length > 0"
class="ml-4 px-4 py-2 bg-red-200 text-red-600 text-sm"
>
@{{ validation.password }}
</span>
</li>
<li class="mt-2 flex items-center">
<button
class="mx-2 my-2 px-2 py-1 text-white font-bold bg-green-600 hover:bg-green-500 rounded-md"
@click="login"
>
login
</button>
<p v-show="loginMessage.length > 0">@{{ loginMessage }}</p>
</li>
</ul>
<p
v-if="fetchUsersMessage.length > 0"
class="ml-4 mt-2 text-green-600 text-md"
>
@{{ fetchUsersMessage }}
</p>
<ul v-if="users.length > 0" class="ml-4 mt-2">
<li v-for="user in users" class="my-2 px-2 hover:bg-green-200">
@{{ user.id }}: @{{ user.name }} <@{{ user.email }}>
</li>
</ul>
</div>
<x-spa.index-script />
</body>
</html>
Vue.jsを切り出したBladeテンプレートを作成します。
※CDN版Vue.js + Bladeなので見易さ重視で分割しています。
php artisan make:view components/spa/index-script
「resources/views/components/spa/index-script.blade.php」を編集します。
<script>
Vue.createApp({
data () {
return {
email: '',
password: '',
validation: {
validated: false,
email: '',
password: '',
},
xsrf_token: '',
urlBase: location.protocol + '//' + location.host,
isLoggedIn: false,
loginMessage: '',
users: [],
fetchUsersMessage: '',
};
},
methods: {
// バリデーション
validate () {
this.validation = {
validated: false,
email: '',
password: '',
};
var error = false;
if (!/[^\@]+\@.*[^\.]{3,}\.[^\.]{2,}/.test(this.email)) {
this.validation.email = '正しいemailを入力してください。';
error = true;
}
if (this.password.length < 8) {
this.validation.password = 'passwordは8文字以上で入力してください。';
error = true;
}
this.validation.validated = !error;
console.log('Validated:', this.validation.validated);
},
// CSRF Protecton 初期化
async initCsrfProtection () {
const url = this.urlBase + '/sanctum/csrf-cookie';
const res = await axios.get(url);
// COOKIEから「XSRF-TOKEN」を取得
this.xsrf_token = $cookies.get('XSRF-TOKEN');
},
// ログイン処理
async login () {
this.users = [];
this.fetchUsersMessage = '';
this.isLoggedin = false;
// バリデーション
this.validate();
if (!this.validation.validated) {
this.loginMessage = '';
return;
}
this.loginMessage = 'ログイン処理中です';
// CSRF Protection 初期化
this.initCsrfProtection();
// ログイン処理
const url = this.urlBase + '/api/login';
const res = await axios.post(
url,
{
email: this.email,
password: this.password,
}
).then(
(response) => {
if (response.data.success) {
console.log('Logged in.');
this.loginMessage = 'ログイン成功';
this.isLoggedin = true;
this.fetchUsers();
} else {
console.log('Login failed.');
this.loginMessage = 'ログイン失敗';
}
}
).catch(
(error) => {
console.log('Error: Login failed.');
this.loginMessage = 'ログイン失敗';
console.error(error);
}
);
},
// ユーザー一覧取得
async fetchUsers () {
this.fetchUsersMessage = 'ユーザー一覧取得処理中...';
const url = this.urlBase + '/api/users';
const res = axios.get(url)
.then((response) => {
console.log(response);
if (response.data.success) {
console.log('Fetching users succeeded.');
this.fetchUsersMessage = 'ユーザー一覧取得成功';
this.users = response.data.data;
} else {
console.log('Fetching users failed.');
this.fetchUsersMessage = 'ユーザー一覧取得失敗';
}
})
.catch((error) => {
console.error(error);
this.fetchUsersMessage = 'ユーザー一覧取得エラー';
});
},
},
mounted: function () {
// axios設定
axios.defaults.withCredentials = true;
axios.defaults.withXSRFToken = true;
},
}).mount('#app');
</script>
ルーティング
「routes/app.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('/', function () {
return view('welcome');
});
Route::get('/spa', [SpaController::class, 'index'])->name('spa');
ビルトインサーバー起動
LaravelビルトインのWEBサーバーを起動します。
php artisan serve
ブラウザでの動作確認
WEBブラウザで次のURLにアクセスします。
http://127.0.0.1:8000/
Laravelデフォルトページが表示され、右上に「Log in」が表示されています。このリンクをクリックしてみます。
ログイン前のJSONが出力されています。
次のURLにアクセスしてみます。
http://127.0.0.1:8000/api/users
ログイン前のページにリダイレクトされました。
SPAにアクセスしてみます。
http://127.0.0.1:8000/spa
ログインフォームが表示されました。
何も入力せずに「login」ボタンをクリックしてみます。
バリデーションが機能しています。
存在しないユーザーのemailとパスワードを適当に入力して
「login」ボタンをクリックしてみます。
ログイン失敗しました。
今度は、DBから適当にemailをコピペします。
パスワードには「password」を入力して「login」ボタンをクリックしてみます。
ログイン成功し、ユーザー一覧取得も成功し、
表示も反映されました。
仕組みのざっくり説明
SanctumによるSPAの認証の仕組みは次のようになっています。
1.API Clientが「/sanctum/csrf-cookie」にアクセス
→ XSRF-TOKENが初期化され発行される
→ XSRF-TOKENがSESSIONに保存される
→ API Client側でクッキーに保存される
2.API ClientがAPIへアクセス
→ リクエストヘッダにXSRF-TOKENを含める
3.Sanctumでの認証
→ CORSの照合(あれば)
→ XSRF-TOKENをSESSIONと照合
→ 不整合なら route(‘login’)にリダイレクト
→ 整合ならコントローラーの処理に移行
といったところです。
ちなみに、デフォルトの設定では、
発行された「XSRF-TOKEN」は、DBの「sessions」テーブルに暗号化されて保存されます。
「sessions」テーブルの該当レコードを削除することで「XSRF-TOKEN」を無効にできます。
有効期限の設定もありますが、当記事では言及しません。
以上です。
参考サイト
▼Laravel公式
▼Laravel 11 REST API Authentication using Sanctum Tutorial
コメント