認証・認可設計 — JWT と層別の責務分担
この章で学ぶこと
この章では、DDDとクリーンアーキテクチャにおける認証(Authentication)と認可(Authorization) の設計について学びます。
- 認証と認可の違い、各層での責務
- JWT(JSON Web Token)の仕組みと構造
- Laravel × JWT認証の実装
- DDDにおける認可設計のパターン
- Chapter 02「Laravel API開発の基礎」: Laravel Sanctumによるトークン認証の基礎
- Chapter 12「プレゼンテーション層」: FormRequestによるバリデーション
- Chapter 16「エラーハンドリング」: ドメイン例外とHTTPステータスコードのマッピング
本章ではより詳細な認証設計と、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)
| クレーム | 名前 | 説明 |
|---|---|---|
sub | Subject | トークンの対象(通常はユーザーID) |
iss | Issuer | トークンの発行者 |
iat | Issued At | トークンの発行時刻(Unix時間) |
exp | Expiration | トークンの有効期限(Unix時間) |
nbf | Not Before | この時刻以前は無効 |
aud | Audience | トークンの対象者 |
アクセストークンとリフレッシュトークン
JWTを使った認証では、2種類のトークンを使い分けるのが一般的です。
トークンのリフレッシュフロー:
アクセストークンの有効期限を短くすることで、万が一トークンが漏洩しても被害を最小限に抑えられます。しかし、短い有効期限だけではユーザー体験が悪化するため、リフレッシュトークンを使って透過的にアクセストークンを更新します。
Sanctum vs JWT の使い分け
| 観点 | Laravel Sanctum | JWT(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-auth は v2.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,
];
}
}
認証コントローラの実装
以下のコードで使用するLoginRequestとRegisterRequestは、Chapter 12「プレゼンテーション層」で解説したFormRequestパターンに基づくバリデーションクラスです。emailとpasswordのバリデーションを含むシンプルな実装です。
// 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 でユーザー権限チェック、ドメイン層で状態チェック |
参考リソース
- RFC 7519 - JSON Web Token (JWT) - JWT公式仕様
- JWT.io - JWTのデコード・検証ツール
- tymon/jwt-auth Documentation - Laravel JWT認証パッケージ
- Auth0 - Refresh Tokens - リフレッシュトークンのベストプラクティス
- Laravel公式ドキュメント - Authentication
- Laravel公式ドキュメント - Authorization
次のチャプターでは、これまで学んだ知識を統合して、注文システムを実際に実装していきます。