Laravel 認証システム—Sanctum
SanctumはLaravelの「門番」です。Webページのセッション認証とAPIのトークン認証の両方を処理し, 単一フレームワーク内で2つの認証システムを提供します。
1. 学ぶ内容
- Laravel Sanctumのインストールと設定:SPA認証 + トークン認証
- 登録/ログイン/ログアウトフロー:デュアルチャネルアプローチ (Webセッション + APIトークン)
- トークン機能と権限:"abilities"フィールドによるきめ細かい制御
- マルチテナント認証分離:テナント対応ミドルウェアとスコープ
- パスワードリセットとメール認証フロー
2. セキュリティエンジニアからの実話
(1) ペインポイント:API認証方式の混同によるセキュリティインシデント
BobはShopMetrics APIにJWTを使用し, Webにセッションを使用していました。2つの別々の認証システムがあり, ユーザーのログイン状態が同期されていません。Aliceがブラウザでログインした後も, API呼び出しのために別のトークンを取得する必要があり, 頻繁に期限切れとなって操作が中断されました。さらに深刻なことに, Charlieは単一のAPIトークンですべてのテナントのデータにアクセスできることを発見しました。テナント分離がなく, テナント間データ漏洩の重大なリスクがありました。
(2) 「Sanctum」によるソリューション
SanctumはWebとAPIの認証を統一します。SPAモードではブラウザとAPIがセッションを共有し, トークンモードではサードパーティクライアントにAPIトークンを提供し, abilitiesフィールドで権限をきめ細かく制御します。
// SPA認証 — WebとAPIでセッションを共有
// APIルートにSanctumのミドルウェアを配置するだけ
Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
return $request->user();
});
// トークン認証 — サードパーティクライアント用
$token = $user->createToken('api-token', ['read-orders', 'write-products']);
// トークンはabilitiesで許可されたことしかできない
(3) 成果
Bobのシングルサインオン後, AliceのSPA体験はシームレスで透過的, Charlieのトークン権限は「注文の読み取りのみ」に正確に制限され, テナント間データ漏洩のリスクはゼロに減少しました。
3. Sanctumのインストールと設定
(1) インストール
composer require laravel/sanctum
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
php artisan migrate
# 作成される:personal_access_tokensテーブル
(2) 設定
// config/sanctum.php
'middleware' => [
'verify_csrf_token' => App\Http\Middleware\VerifyCsrfToken::class,
'encrypt_cookies' => App\Http\Middleware\EncryptCookies::class,
],
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
'%s%s',
'localhost,localhost:3000,localhost:5173,127.0.0.1,127.0.0.1:8000',
env('APP_URL') ? ',' . parse_url(env('APP_URL'), PHP_URL_HOST) : '',
))),
'expiration' => null, // トークンは期限切れなし (または分単位で設定)
(3) SPAフロントエンド設定
// resources/js/bootstrap.js
import axios from 'axios';
window.axios = axios;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
window.axios.defaults.withCredentials = true; // APIリクエストでCookieを送信
| 設定オプション | 機能 | デフォルト値 |
|---|---|---|
stateful |
SPA認証ドメイン | localhost:3000など |
expiration |
トークン有効期限 | null (期限切れなし) |
guard |
認証ガード | web |
(1) ▶ サンプル:Sanctum SPA認証設定
# .env — SPAドメインの設定
SESSION_DOMAIN=localhost
SANCTUM_STATEFUL_DOMAINS=localhost:3000,localhost:5173
SESSION_DRIVER=redis
# Sanctumのインストールと設定
composer require laravel/sanctum
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
php artisan migrate
出力:
# コマンド実行成功
4. 登録/ログイン/ログアウトフロー
(1) 登録
// app/Http/Controllers/Auth/RegisterController.php
class RegisterController extends Controller
{
public function register(RegisterRequest $request): JsonResponse
{
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
'tenant_id' => $request->tenant_id,
'role' => 'tenant_owner',
]);
event(new Registered($user));
Auth::login($user);
return response()->json([
'user' => $user,
'message' => 'Registration successful.',
], 201);
}
}
(2) ログイン
// app/Http/Controllers/Auth/LoginController.php
class LoginController extends Controller
{
public function login(LoginRequest $request): JsonResponse
{
if (!Auth::attempt($request->only('email', 'password'))) {
throw ValidationException::withMessages([
'email' => ['The provided credentials are incorrect.'],
]);
}
$request->session()->regenerate();
return response()->json([
'user' => Auth::user(),
'message' => 'Login successful.',
]);
}
public function logout(Request $request): JsonResponse
{
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return response()->json(['message' => 'Logged out.']);
}
}
(3) トークン認証によるログイン
// app/Http/Controllers/Auth/ApiTokenController.php
class ApiTokenController extends Controller
{
public function login(LoginRequest $request): JsonResponse
{
$user = User::where('email', $request->email)->first();
if (!$user || !Hash::check($request->password, $user->password)) {
throw ValidationException::withMessages([
'email' => ['Invalid credentials.'],
]);
}
$token = $user->createToken(
$request->device_name ?? 'api-token',
$request->abilities ?? ['*'],
);
return response()->json([
'user' => $user,
'token' => $token->plainTextToken,
]);
}
public function logout(Request $request): JsonResponse
{
$request->user()->currentAccessToken()->delete();
return response()->json(['message' => 'Token revoked.']);
}
}
| モード | ログイン方法 | 状態の永続化 | 適用場面 |
|---|---|---|---|
| SPAセッション | フォームログイン | Cookie/セッション | SPAフロントエンド |
| トークン | plainTextTokenの取得 | トークン文字列 | モバイル/サードパーティ |
(1) ▶ サンプル:ShopMetricsデュアルモード認証ルーティング
// routes/web.php — SPA向けセッション認証
Route::post('/login', [LoginController::class, 'login']);
Route::post('/logout', [LogoutController::class, 'logout'])->middleware('auth');
Route::post('/register', [RegisterController::class, 'register']);
// routes/api.php — API向けトークン認証
Route::post('/tokens', [ApiTokenController::class, 'login']);
Route::middleware('auth:sanctum')->group(function () {
Route::delete('/tokens/current', [ApiTokenController::class, 'logout']);
Route::get('/user', fn (Request $request) => $request->user());
});
出力:
// 実行成功
5. トークン機能と権限
(1) abilities付きトークンの作成
// 特定のabilitiesを持つトークンを作成
$token = $user->createToken('analytics-read', [
'read-analytics',
'read-orders',
]);
// フルアクセストークン
$token = $user->createToken('admin-token', ['*']);
// 有効期限付きトークン
$token = $user->createToken('temp-token', ['read-orders'], now()->addDays(7));
(2) トークン機能のチェック
// コントローラまたはミドルウェア内
if ($request->user()->tokenCan('read-analytics')) {
return AnalyticsResource::collection($analytics);
}
// ミドルウェア内
class CheckAbilities
{
public function handle($request, $next, ...$abilities)
{
foreach ($abilities as $ability) {
if (!$request->user()->tokenCan($ability)) {
abort(403, 'Insufficient permissions.');
}
}
return $next($request);
}
}
(3) abilities権限マトリックス
| ロール | Abilities | 説明 |
|---|---|---|
| スーパー管理者 | * |
フル権限 |
| テナントオーナー | read-*, write-* |
テナント内のフル権限 |
| アナリスト | read-analytics, read-orders |
読み取り専用 |
| APIクライアント | read-products, write-orders |
サードパーティ限定 |
(1) ▶ サンプル:ShopMetricsトークンアクセス制御
// app/Http/Controllers/Api/TokenController.php
class TokenController extends Controller
{
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'abilities' => 'sometimes|array',
'abilities.*' => 'in:read-analytics,read-orders,write-orders,read-products,write-products',
'expires_in_days' => 'sometimes|integer|min:1|max:365',
]);
$abilities = $validated['abilities'] ?? match ($request->user()->role) {
'super_admin' => ['*'],
'tenant_owner' => ['read-analytics', 'read-orders', 'write-orders', 'read-products', 'write-products'],
'analyst' => ['read-analytics', 'read-orders', 'read-products'],
default => [],
};
$expiresAt = isset($validated['expires_in_days'])
? now()->addDays($validated['expires_in_days'])
: null;
$token = $request->user()->createToken(
$validated['name'],
$abilities,
$expiresAt,
);
return response()->json([
'token' => $token->plainTextToken,
'abilities' => $abilities,
], 201);
}
}
出力:
// 実行成功
6. マルチテナント認証分離
(1) Sanctumトークン認証シーケンス図
sequenceDiagram
participant C as クライアント
participant S as Sanctum
participant DB as データベース
participant M as ミドルウェア
C->>S: POST /api/tokens (email + password)
S->>DB: ユーザー検索 + パスワード確認
DB-->>S: ユーザー発見
S->>DB: personal_access_tokenを作成
DB-->>S: トークン作成済み
S-->>C: plainTextTokenを返却
C->>M: GET /api/shops (Bearer token)
M->>DB: personal_access_tokensでトークン検索
DB-->>M: トークン + ユーザー + abilities
M->>M: tokenCan()のabilityを確認
M->>M: テナントスコープを確認
M-->>C: テナントスコープのデータを返却
(2) テナント認証ミドルウェア
// app/Http/Middleware/TenantResolve.php
class TenantResolve
{
public function handle(Request $request, Closure $next)
{
$user = $request->user();
if (!$user || !$user->tenant_id) {
abort(403, 'No tenant associated.');
}
$tenant = $user->tenant;
if ($tenant->status !== 'active') {
abort(403, 'Tenant account is suspended.');
}
// すべてのクエリにテナントコンテキストを設定
app()->instance(Tenant::class, $tenant);
return $next($request);
}
}
(3) マルチテナントスコープ
// app/Models/TenantScope.php
class TenantScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
if ($tenant = app()->make(Tenant::class)) {
$builder->where($model->getTable() . '.tenant_id', $tenant->id);
}
}
}
// モデルに適用
class Shop extends Model
{
protected static function booted(): void
{
static::addGlobalScope(new TenantScope);
}
}
(1) ▶ サンプル:ShopMetricsマルチテナント認証ルーティング
// routes/api.php
Route::middleware('auth:sanctum')->group(function () {
// すべてのAPIルートにテナント解決が必要
Route::middleware('tenant.resolve')->prefix('v1')->group(function () {
Route::apiResource('shops', Api\ShopController::class);
Route::apiResource('products', Api\ProductController::class);
Route::apiResource('orders', Api\OrderController::class)
->only(['index', 'show', 'update']);
// アナリティクス — 特定のabilityが必要
Route::get('analytics', [Api\AnalyticsController::class, 'index'])
->middleware('ability:read-analytics');
// 管理者専用ルート
Route::middleware('ability:*')->prefix('admin')->group(function () {
Route::apiResource('plans', Api\Admin\PlanController::class);
Route::apiResource('tenants', Api\Admin\TenantController::class);
});
});
});
出力:
// 実行成功
7. パスワードリセットとメール認証
(1) パスワードリセット
// routes/web.php
Route::post('/forgot-password', [PasswordResetController::class, 'sendResetLink']);
Route::post('/reset-password', [PasswordResetController::class, 'reset']);
// app/Http/Controllers/PasswordResetController.php
class PasswordResetController extends Controller
{
public function sendResetLink(Request $request): JsonResponse
{
$request->validate(['email' => 'required|email|exists:users']);
$status = Password::sendResetLink($request->only('email'));
return response()->json([
'message' => $status === Password::RESET_LINK_SENT
? 'Reset link sent to your email.'
: 'Unable to send reset link.',
]);
}
}
(2) メール認証
// モデルはMustVerifyEmailを実装する必要がある
class User extends Model implements MustVerifyEmail
{
use Notifiable, VerifiesEmails;
}
// 保護ルート — 認証済みユーザーのみ
Route::middleware(['auth:sanctum', 'verified'])->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index']);
});
// APIメール認証
Route::middleware('auth:sanctum')->group(function () {
Route::post('/email/verification-notification', function (Request $request) {
$request->user()->sendEmailVerificationNotification();
return response()->json(['message' => 'Verification link sent.']);
});
});
(1) ▶ サンプル:ShopMetrics認証フローの完全設定
// bootstrap/app.php — 認証ミドルウェアの設定
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'tenant.resolve' => TenantResolve::class,
'ability' => CheckAbilities::class,
]);
})
// 認証付きUserモデル
class User extends Authenticatable implements MustVerifyEmail
{
use HasApiTokens, Notifiable;
protected $fillable = [
'tenant_id', 'name', 'email', 'password', 'role', 'email_verified_at',
];
protected $casts = [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
}
出力:
// 実行成功
8. 総合例:ShopMetrics完全認証システム
// ============================================
// 総合:ShopMetrics認証システム
// カバー:SPA認証, トークン認証, abilities, テナント分離
// ============================================
// routes/api.php — 完全なAPI認証ルート
Route::prefix('auth')->group(function () {
Route::post('/register', [Auth\RegisterController::class, 'register']);
Route::post('/login', [Auth\ApiTokenController::class, 'login']);
Route::post('/forgot-password', [Auth\PasswordResetController::class, 'sendResetLink']);
Route::post('/reset-password', [Auth\PasswordResetController::class, 'reset']);
Route::middleware('auth:sanctum')->group(function () {
Route::get('/user', fn (Request $r) => $r->user()->load('tenant'));
Route::post('/logout', [Auth\ApiTokenController::class, 'logout']);
Route::post('/email/verification', function (Request $request) {
$request->user()->sendEmailVerificationNotification();
return response()->json(['message' => 'Verification email sent.']);
});
Route::post('/tokens', [Auth\TokenController::class, 'store']);
Route::get('/tokens', fn (Request $r) => $r->user()->tokens);
Route::delete('/tokens/{id}', fn (Request $r, $id) => $r->user()->tokens()->where('id', $id)->delete());
});
});
// 保護されたAPIルート
Route::middleware(['auth:sanctum', 'verified', 'tenant.resolve'])
->prefix('v1')->group(function () {
Route::apiResource('shops', Api\ShopController::class);
Route::apiResource('products', Api\ProductController::class);
Route::apiResource('orders', Api\OrderController::class)->only(['index', 'show', 'update']);
Route::get('analytics/overview', [Api\AnalyticsController::class, 'overview'])
->middleware('ability:read-analytics');
Route::post('reports/generate', [Api\ReportController::class, 'generate'])
->middleware('ability:write-reports');
});
❓ よくある質問
withCredentials: trueを設定するだけで, SanctumがCSRFとセッションを自動的に処理します。expiration (分単位)を設定するか, トークン作成時に第3パラメータで$user->createToken('name', ['*'], now()->addDays(30))と渡してください。期限切れトークンは自動的に無効化されます。$user->tokens()->delete()でユーザーの全トークンを失効させ, $user->currentAccessToken()->delete()で現在のトークンのみを失効させます。📖 まとめ
- SanctumはWebセッションとAPIトークンの2つの認証機構を統一します
- SPAモードではCookie/セッションで認証し, トークン管理が不要です
- トークンモードでは
plainTextTokenでサードパーティクライアントにAPIアクセスを提供します - トークンabilities:権限をきめ細かく制御し, 過剰なアクセス付与を防ぎます
- TenantResolveミドルウェア + TenantScopeでマルチテナント認証分離を実装します
- メール認証とパスワードリセットはすぐに使える状態です
📝 練習問題
-
基本問題 (⭐):Sanctum SPA認証を設定し, 登録, ログイン, ログアウトの3つのAPIエンドポイントを実装し, Postmanでセッション認証フローをテストしてください。
-
応用問題 (⭐⭐):トークンベース認証モデルを実装し, abilities付きトークンを作成し,
tokenCan()権限をチェックするミドルウェアを記述して, 「アナリスト」ロールがデータの読み取りのみできることを確認してください。 -
チャレンジ (⭐⭐⭐):TenantScopeとTenantResolveミドルウェアを使用してマルチテナント分離を実装し, APIトークンが自身のテナントのデータのみにアクセスできることを確保し, テナント間リクエストが403エラーを返すようにしてください。



