メインコンテンツまでスキップ

認証・認可設計 — JWT と層別の責務分担

この章で学ぶこと

この章では、DDDとクリーンアーキテクチャにおける認証(Authentication)と認可(Authorization) の設計について学びます。

  • 認証と認可の違い、各層での責務
  • JWT(JSON Web Token)の仕組みと構造
  • Laravel × JWT認証の実装
  • DDDにおける認可設計のパターン
関連する章

本章ではより詳細な認証設計と、SPA向けに広く使われるJWT認証について解説します。

認証と認可の違い

認証(Authentication)と認可(Authorization)は、しばしば混同されますが、明確に異なる概念です。

概念英語目的質問
認証Authenticationユーザーの身元確認「あなたは誰ですか?」
認可Authorization権限の確認「あなたはこれをする権限がありますか?」

クリーンアーキテクチャにおける認証・認可の位置づけ

認証と認可は、異なる層で扱うべき責務です。

認証はプレゼンテーション層、認可はアプリケーション層とドメイン層

認証は「誰がリクエストしているか」を特定する処理であり、HTTPリクエストの解析が必要なためプレゼンテーション層で行います。一方、認可は「その操作が許可されるか」のビジネスルールであり、アプリケーション層やドメイン層で判定します。

JWT(JSON Web Token)の基礎

JWTとは

JWTは、RFC 7519で定義された、JSON形式のクレーム(主張)を安全に転送するためのトークン形式です。署名により改ざんを検知でき、ステートレスな認証を実現できます。

JWTの構造

JWTは3つの部分で構成され、それぞれBase64URLエンコードされてピリオド(.)で連結されます。

JWT は Header.Payload.Signature の 3 つを Base64URL エンコードしてピリオドで連結します。

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIi...
└──────── Header ────────┘.└──────── Payload ────────...

1. Header(ヘッダー)

{
"alg": "HS256", // 署名アルゴリズム(HMAC SHA-256)
"typ": "JWT" // トークンタイプ
}

2. Payload(ペイロード) — クレーム(主張)を含みます。

{
"sub": "123", // Subject: ユーザーID
"iss": "my-app", // Issuer: 発行者
"iat": 1699999999, // Issued At: 発行時刻
"exp": 1700000899, // Expiration: 有効期限
"role": "admin" // カスタムクレーム
}

3. Signature(署名)

HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)

ヘッダーとペイロードを秘密鍵で署名し、改ざんを検知可能にします。

主要なクレーム(Registered Claims)

クレーム名前説明
subSubjectトークンの対象(通常はユーザーID)
issIssuerトークンの発行者
iatIssued Atトークンの発行時刻(Unix時間)
expExpirationトークンの有効期限(Unix時間)
nbfNot Beforeこの時刻以前は無効
audAudienceトークンの対象者

アクセストークンとリフレッシュトークン

JWTを使った認証では、2種類のトークンを使い分けるのが一般的です。

トークンのリフレッシュフロー:

なぜ2種類のトークンを使うのか

アクセストークンの有効期限を短くすることで、万が一トークンが漏洩しても被害を最小限に抑えられます。しかし、短い有効期限だけではユーザー体験が悪化するため、リフレッシュトークンを使って透過的にアクセストークンを更新します。

Sanctum vs JWT の使い分け

観点Laravel SanctumJWT(tymon/jwt-auth)
トークン形式ランダム文字列(DBに保存)自己完結型JWT
状態管理ステートフル(DB照会必要)ステートレス(署名検証のみ)
スケーラビリティDB負荷が増加高い(DB不要)
トークン無効化即座に可能有効期限まで無効化困難
適したケース小〜中規模SPA、ファーストパーティAPI大規模システム、マイクロサービス
セットアップLaravel標準追加パッケージ必要
どちらを選ぶべきか
  • Sanctum: Laravelと同一ドメインのSPAや、トークンの即時無効化が必要な場合
  • JWT: 複数サービス間での認証共有、高いスケーラビリティが必要な場合

本書ではJWT認証を解説しますが、プロジェクトの要件に応じて適切な方式を選択してください。

Laravel × JWT認証の実装

パッケージのインストール

Laravel向けのJWT認証ライブラリとして広く使われているtymon/jwt-authを使用します。

対応バージョン

本章のコードは Laravel 11 以上、PHP 8.2 以上を前提としています。tymon/jwt-authv2.3.0(2026-03 リリース)以降が Laravel 9 〜 13 に対応しており、本書では ^2.3 の安定版を使用します。

# パッケージのインストール(安定版 ^2.3)
composer require tymon/jwt-auth:^2.3

# 設定ファイルの公開
php artisan vendor:publish --provider="Tymon\JWTAuth\Providers\LaravelServiceProvider"

# JWT署名用の秘密鍵を生成(.envに JWT_SECRET が追加される)
php artisan jwt:secret

認証ガードの設定

// config/auth.php

return [
'defaults' => [
'guard' => 'api', // デフォルトをAPIガードに
'passwords' => 'users',
],

'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],

'api' => [
'driver' => 'jwt', // JWTドライバーを使用
'provider' => 'users',
],
],

'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => App\Models\User::class,
],
],
];

Userモデルの実装

JWTSubjectインターフェースを実装する必要があります。

// app/Models/User.php

namespace App\Models;

use Illuminate\Foundation\Auth\User as Authenticatable;
use Tymon\JWTAuth\Contracts\JWTSubject;

class User extends Authenticatable implements JWTSubject
{
protected $fillable = [
'name',
'email',
'password',
];

protected $hidden = [
'password',
];

/**
* JWTの識別子(通常はプライマリキー)
*
* 戻り値はプライマリキー型に合わせて int もしくは string を返す。
* JWTSubject インターフェース側は mixed だが、共変戻り値として
* int|string に狭めることで型安全性を高める。
*/
public function getJWTIdentifier(): int|string
{
return $this->getKey();
}

/**
* JWTに含めるカスタムクレーム
* 必要に応じてロールなどを追加
*
* @return array<string, mixed>
*/
public function getJWTCustomClaims(): array
{
return [
// 例: 'role' => $this->role,
];
}
}

認証コントローラの実装

FormRequestについて

以下のコードで使用するLoginRequestRegisterRequestは、Chapter 12「プレゼンテーション層」で解説したFormRequestパターンに基づくバリデーションクラスです。emailpasswordのバリデーションを含むシンプルな実装です。

// app/Http/Controllers/AuthController.php

namespace App\Http\Controllers;

use App\Http\Requests\LoginRequest;
use App\Http\Requests\RegisterRequest;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Hash;

final class AuthController extends Controller
{
/**
* ユーザー登録
*/
public function register(RegisterRequest $request): JsonResponse
{
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);

$token = auth()->login($user);

return $this->respondWithToken($token, 201);
}

/**
* ログイン
*/
public function login(LoginRequest $request): JsonResponse
{
$credentials = $request->only(['email', 'password']);

if (!$token = auth()->attempt($credentials)) {
return response()->json([
'error' => 'メールアドレスまたはパスワードが正しくありません',
'code' => 'INVALID_CREDENTIALS',
], 401);
}

return $this->respondWithToken($token);
}

/**
* ログアウト(トークンを無効化)
*/
public function logout(): JsonResponse
{
auth()->logout();

return response()->json([
'message' => 'ログアウトしました',
]);
}

/**
* トークンのリフレッシュ
*/
public function refresh(): JsonResponse
{
return $this->respondWithToken(auth()->refresh());
}

/**
* 認証中のユーザー情報を取得
*/
public function me(): JsonResponse
{
return response()->json(auth()->user());
}

/**
* トークンを含むレスポンスを生成
*/
private function respondWithToken(string $token, int $status = 200): JsonResponse
{
return response()->json([
'access_token' => $token,
'token_type' => 'bearer',
'expires_in' => auth()->factory()->getTTL() * 60, // 秒単位
], $status);
}
}

ルーティングの設定

// routes/api.php

use App\Http\Controllers\AuthController;
use App\Http\Controllers\OrderController;

// 認証不要のエンドポイント
Route::prefix('auth')->group(function () {
Route::post('/register', [AuthController::class, 'register']);
Route::post('/login', [AuthController::class, 'login']);
});

// 認証が必要なエンドポイント
Route::middleware('auth:api')->group(function () {
// 認証関連
Route::prefix('auth')->group(function () {
Route::post('/logout', [AuthController::class, 'logout']);
Route::post('/refresh', [AuthController::class, 'refresh']);
Route::get('/me', [AuthController::class, 'me']);
});

// ビジネスロジック
Route::prefix('orders')->group(function () {
Route::get('/', [OrderController::class, 'index']);
Route::post('/', [OrderController::class, 'store']);
Route::get('/{id}', [OrderController::class, 'show']);
Route::post('/{id}/confirm', [OrderController::class, 'confirm']);
Route::post('/{id}/cancel', [OrderController::class, 'cancel']);
});
});

JWT設定のカスタマイズ

// config/jwt.php(抜粋)

return [
// トークンの有効期限(分)
// 本番環境では15〜60分程度を推奨
'ttl' => env('JWT_TTL', 60),

// リフレッシュ可能な期間(分)
// この期間内であれば期限切れトークンをリフレッシュ可能
'refresh_ttl' => env('JWT_REFRESH_TTL', 20160), // 2週間

// 署名アルゴリズム
// HS256: 対称鍵(同じ秘密鍵で署名・検証)
// RS256: 非対称鍵(秘密鍵で署名、公開鍵で検証)
'algo' => env('JWT_ALGO', 'HS256'),

// 必須クレーム
'required_claims' => [
'iss', // 発行者
'iat', // 発行時刻
'exp', // 有効期限
'nbf', // 有効開始時刻
'sub', // 対象(ユーザーID)
'jti', // トークンID(一意識別子)
],
];

DDDにおける認可設計

認可の責務分担

認可は、アプリケーション層とドメイン層で行います。

UseCaseでの認可実装

まず、UseCaseに渡すCommandクラスを定義します。

// app/Application/UseCase/Order/CancelOrderCommand.php

namespace App\Application\UseCase\Order;

final class CancelOrderCommand
{
public function __construct(
public readonly int $orderId,
public readonly int $requesterId,
public readonly string $requesterRole,
) {}
}

次に、認可チェックを含むUseCaseを実装します。

// app/Application/UseCase/Order/CancelOrderUseCase.php

namespace App\Application\UseCase\Order;

use App\Domain\Order\Exception\OrderNotFoundException;
use App\Domain\Order\OrderId;
use App\Domain\Order\OrderRepositoryInterface;
use App\Domain\Shared\Exception\DomainAuthorizationException;
use App\Domain\User\UserId;

final class CancelOrderUseCase
{
public function __construct(
private readonly OrderRepositoryInterface $orderRepository,
) {}

/**
* @throws OrderNotFoundException
* @throws DomainAuthorizationException
*/
public function execute(CancelOrderCommand $command): void
{
$orderId = new OrderId($command->orderId);
$requesterId = new UserId($command->requesterId);

$order = $this->orderRepository->findById($orderId)
?? throw new OrderNotFoundException($orderId);

// アプリケーション層での認可チェック
// 注文者本人のみキャンセル可能
if (!$order->isOwnedBy($requesterId)) {
throw new DomainAuthorizationException(
'この注文をキャンセルする権限がありません'
);
}

// ドメイン層での状態チェック(ビジネスルール)
// cancel()メソッド内で状態検証を行う
$order->cancel();

$this->orderRepository->save($order);
}
}

ドメイン層での認可表現

// app/Domain/Order/Order.php

namespace App\Domain\Order;

use App\Domain\Order\Exception\InvalidOrderStateException;
use App\Domain\User\UserId;

final class Order
{
private function __construct(
private readonly OrderId $id,
private readonly UserId $userId, // 注文者
private OrderStatus $status,
// ...
) {}

/**
* この注文が指定ユーザーのものか判定
*/
public function isOwnedBy(UserId $userId): bool
{
return $this->userId->equals($userId);
}

/**
* 注文をキャンセル(ドメインルールによる制約)
*/
public function cancel(): void
{
if (!$this->status->canBeCancelled()) {
throw new InvalidOrderStateException(
'この状態の注文はキャンセルできません。現在の状態: ' . $this->status->value
);
}

$this->status = OrderStatus::CANCELLED;
$this->recordEvent(new OrderCancelled($this->id));
}
}
// app/Domain/Order/OrderStatus.php

namespace App\Domain\Order;

enum OrderStatus: string
{
case DRAFT = 'draft';
case CONFIRMED = 'confirmed';
case SHIPPED = 'shipped';
case CANCELLED = 'cancelled';

/**
* キャンセル可能な状態か判定
* ビジネスルール: 発送済みの注文はキャンセル不可
*/
public function canBeCancelled(): bool
{
return match($this) {
self::DRAFT, self::CONFIRMED => true,
self::SHIPPED, self::CANCELLED => false,
};
}
}

認可例外の定義

// app/Domain/Shared/Exception/DomainAuthorizationException.php

namespace App\Domain\Shared\Exception;

final class DomainAuthorizationException extends DomainException
{
public function __construct(string $message = '権限がありません')
{
parent::__construct(
message: $message,
errorCode: 'UNAUTHORIZED_ACTION',
);
}
}

例外ハンドリングでの対応

Chapter 16「エラーハンドリング」bootstrap/app.php->withExceptions() に、DomainAuthorizationException を 403 にマッピングする分岐を追加します。

// bootstrap/app.php の withExceptions 内、DomainException の render に追加
$status = match (true) {
$e instanceof DomainAuthorizationException => Response::HTTP_FORBIDDEN, // 403
$e instanceof OrderNotFoundException => Response::HTTP_NOT_FOUND,
// ...
default => Response::HTTP_BAD_REQUEST,
};

ロールベースの認可(オプション)

管理者など、ロールに基づく認可が必要な場合の実装例です。

// app/Domain/User/Role.php

namespace App\Domain\User;

enum Role: string
{
case USER = 'user';
case ADMIN = 'admin';

public function canViewAllOrders(): bool
{
return $this === self::ADMIN;
}

public function canCancelAnyOrder(): bool
{
return $this === self::ADMIN;
}
}
// app/Application/UseCase/Order/CancelOrderUseCase.php(ロール対応版)

public function execute(CancelOrderCommand $command): void
{
$orderId = new OrderId($command->orderId);
$requesterId = new UserId($command->requesterId);
$requesterRole = Role::from($command->requesterRole);

$order = $this->orderRepository->findById($orderId)
?? throw new OrderNotFoundException($orderId);

// 管理者は全注文をキャンセル可能、一般ユーザーは自分の注文のみ
$canCancel = $requesterRole->canCancelAnyOrder()
|| $order->isOwnedBy($requesterId);

if (!$canCancel) {
throw new DomainAuthorizationException(
'この注文をキャンセルする権限がありません'
);
}

$order->cancel();
$this->orderRepository->save($order);
}

Controllerでのユーザー情報取得

// app/Http/Controllers/OrderController.php

namespace App\Http\Controllers;

use App\Application\UseCase\Order\CancelOrderCommand;
use App\Application\UseCase\Order\CancelOrderUseCase;
use Illuminate\Http\JsonResponse;

final class OrderController extends Controller
{
public function __construct(
private readonly CancelOrderUseCase $cancelOrderUseCase,
) {}

public function cancel(int $id): JsonResponse
{
// auth()->user() でJWTから認証済みユーザーを取得
/** @var \App\Models\User $user */
$user = auth()->user();

$command = new CancelOrderCommand(
orderId: $id,
requesterId: $user->id,
requesterRole: $user->role,
);

$this->cancelOrderUseCase->execute($command);

return response()->json([
'message' => '注文をキャンセルしました',
]);
}
}

セキュリティに関する注意点

JWTのセキュリティベストプラクティス

項目推奨事項
有効期限アクセストークンは短く(15分〜1時間)
署名アルゴリズムRS256(非対称鍵)を推奨。HS256の場合は秘密鍵を厳重に管理
ペイロード機密情報(パスワード、個人情報)を含めない
HTTPS必ずHTTPSで通信(トークンの盗聴防止)
リフレッシュトークンHttpOnly Cookieに保存、DB保存時はハッシュ化

トークン無効化の実装(オプション)

JWTはステートレスなため、即座の無効化が困難です。必要な場合は以下の方法を検討してください。

// ブラックリスト方式(tymon/jwt-authの機能)
// config/jwt.php
'blacklist_enabled' => env('JWT_BLACKLIST_ENABLED', true),

// ログアウト時にブラックリストに追加
auth()->logout(); // 内部でブラックリストに追加される
ブラックリストの注意点

ブラックリストはキャッシュ(Redis等)に保存されます。スケーラビリティを重視する場合は、アクセストークンの有効期限を短くし、ブラックリストを使わない設計も検討してください。

まとめ

ポイント説明
認証 vs 認可認証は「誰か」、認可は「何ができるか」
認証の層プレゼンテーション層(ミドルウェア)
認可の層アプリケーション層(UseCase)とドメイン層
JWT構造Header.Payload.Signature
アクセストークン短い有効期限(15分〜1時間)
リフレッシュトークン長い有効期限、HttpOnly Cookieに保存
認可の実装UseCase でユーザー権限チェック、ドメイン層で状態チェック

参考リソース

次のチャプターでは、これまで学んだ知識を統合して、注文システムを実際に実装していきます。