404 Not Found

404 Not Found


nginx

Laravel 認証システム—Sanctum

SanctumはLaravelの「門番」です。Webページのセッション認証とAPIのトークン認証の両方を処理し, 単一フレームワーク内で2つの認証システムを提供します。

1. 学ぶ内容


2. セキュリティエンジニアからの実話

(1) ペインポイント:API認証方式の混同によるセキュリティインシデント

BobはShopMetrics APIにJWTを使用し, Webにセッションを使用していました。2つの別々の認証システムがあり, ユーザーのログイン状態が同期されていません。Aliceがブラウザでログインした後も, API呼び出しのために別のトークンを取得する必要があり, 頻繁に期限切れとなって操作が中断されました。さらに深刻なことに, Charlieは単一のAPIトークンですべてのテナントのデータにアクセスできることを発見しました。テナント分離がなく, テナント間データ漏洩の重大なリスクがありました。

(2) 「Sanctum」によるソリューション

SanctumはWebとAPIの認証を統一します。SPAモードではブラウザとAPIがセッションを共有し, トークンモードではサードパーティクライアントにAPIトークンを提供し, abilitiesフィールドで権限をきめ細かく制御します。

PHP
// 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) インストール

BASH
composer require laravel/sanctum
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
php artisan migrate
# 作成される:personal_access_tokensテーブル

(2) 設定

PHP
// 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フロントエンド設定

JAVASCRIPT
// 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認証設定

BASH
# .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

出力:

TEXT
# コマンド実行成功

4. 登録/ログイン/ログアウトフロー

(1) 登録

PHP
// 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) ログイン

PHP
// 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) トークン認証によるログイン

PHP
// 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デュアルモード認証ルーティング

PHP
// 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());
});

出力:

TEXT
// 実行成功

5. トークン機能と権限

(1) abilities付きトークンの作成

PHP
// 特定の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) トークン機能のチェック

PHP
// コントローラまたはミドルウェア内
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トークンアクセス制御

PHP
// 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);
    }
}

出力:

TEXT
// 実行成功

6. マルチテナント認証分離

(1) Sanctumトークン認証シーケンス図

100%
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) テナント認証ミドルウェア

PHP
// 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) マルチテナントスコープ

PHP
// 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マルチテナント認証ルーティング

PHP
// 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);
        });
    });
});

出力:

TEXT
// 実行成功

7. パスワードリセットとメール認証

(1) パスワードリセット

PHP
// 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) メール認証

PHP
// モデルは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認証フローの完全設定

PHP
// 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);
    }
}

出力:

TEXT
// 実行成功

8. 総合例:ShopMetrics完全認証システム

PHP
// ============================================
// 総合: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');
    });

❓ よくある質問

Q SanctumとPassportの違いは何ですか?
A Sanctumは軽量で, SPAセッションとシンプルなトークン認証をサポートします。PassportはOAuth2の完全な実装 (認可コード, インプリシット, クライアントクレデンシャルなど)を提供します。ほとんどのアプリケーションではSanctumで十分です。OAuth 2.0が必要な場合のみPassportを使用してください。
Q SPAモードではトークンはどこに保存されますか?
A SPAモードではトークンを使用しません!従来のWebアプリケーションと同様にCookie/セッションで認証します。フロントエンドはwithCredentials: trueを設定するだけで, SanctumがCSRFとセッションを自動的に処理します。
Q フロントエンドでトークンはどこに保存すべきですか?
A モバイルではKeychainまたはKeystoreに保存してください。トークンモードのSPAではlocalStorageに保存 (XSSリスクあり)またはHttpOnly Cookieに保存してください。ベストプラクティス:SPAではセッションモードを使用し, トークンの保存を避けてください。
Q トークンの有効期限を設定するにはどうすればよいですか?
A config/sanctum.phpでexpiration (分単位)を設定するか, トークン作成時に第3パラメータで$user->createToken('name', ['*'], now()->addDays(30))と渡してください。期限切れトークンは自動的に無効化されます。
Q マルチテナントシステムでトークンのテナント間使用を防ぐにはどうすればよいですか?
A TenantResolveミドルウェアで, トークンが属するユーザーのtenant_idがリクエストのテナントと一致することを確認してください。または, トークンのabilitiesにテナント情報をエンコードすることもできます。
Q すべてのトークンを失効させるにはどうすればよいですか?
A $user->tokens()->delete()でユーザーの全トークンを失効させ, $user->currentAccessToken()->delete()で現在のトークンのみを失効させます。

📖 まとめ


📝 練習問題

  1. 基本問題 (⭐):Sanctum SPA認証を設定し, 登録, ログイン, ログアウトの3つのAPIエンドポイントを実装し, Postmanでセッション認証フローをテストしてください。

  2. 応用問題 (⭐⭐):トークンベース認証モデルを実装し, abilities付きトークンを作成し, tokenCan()権限をチェックするミドルウェアを記述して, 「アナリスト」ロールがデータの読み取りのみできることを確認してください。

  3. チャレンジ (⭐⭐⭐):TenantScopeとTenantResolveミドルウェアを使用してマルチテナント分離を実装し, APIトークンが自身のテナントのデータのみにアクセスできることを確保し, テナント間リクエストが403エラーを返すようにしてください。

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%