認証・認可設計 — JWT と層別の責務分担
この章で学ぶこと
この章では、DDDとクリーンアーキテクチャにおける認証(Authentication)と認可(Authorization) の設計について学びます。
- 認証と認可の違い、各層での責務
- JWT(JSON Web Token)の仕組みと構造
- Laravel × JWT認証の実装
- DDDにおける認可設計のパターン
- 第2章「Laravel API開発の基礎」: Laravel Sanctumによるトークン認証の基礎
- 第12章「プレゼンテーション層」: FormRequestによるバリデーション
- 第16章「エラーハンドリング」: ドメイン例外とHTTPステータスコードのマッピング
本章ではより詳細な認証設計と、SPA向けに広く使われるJWT認証について解説します。
認証と認可の違い
認証(Authentication)と認可(Authorization)は、しばしば混同されますが、明確に異なる概念です。
| 概念 | 英語 | 目的 | 質問 |
|---|---|---|---|
| 認証 | Authentication | ユーザーの身元確認 | 「あなたは誰ですか?」 |
| 認可 | Authorization | 権限の確認 | 「あなたはこれをする権限がありますか?」 |
クリーンアーキテクチャにおける認証・認可の位置づけ
認証と認可は、異なる層で扱うべき責務です。
認証は「誰がリクエストしているか」を特定する処理であり、HTTPリクエストの解析が必要なためプレゼンテーション層で行います。認可も「誰が何にアクセスできるか」の判定なので、Laravel では Policy がプレゼンテーション境界でこれを担います(第12章)。ドメイン層が持つのは「その操作が業務上許されるか」というビジネスルールです。
JWT(JSON Web Token)の基礎
JWTとは
JWTは、RFC 7519で定義された、JSON形式のクレーム(主張)を安全に転送するためのトークン形式です。署名により改ざんを検知でき、ステートレスな認証を実現できます。
JWTの構造
JWTは Header.Payload.Signature の3つの部分から成り、それぞれをBase64URLエンコードしてピリオド(.)で連結します。
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 . eyJzdWIiOiIxMjMiLCJpc3MiOiJteS1hcHAifQ . SflKxwRJSMeKKF2QT4f
─────────── Header ───────────────── . ────────────── Payload ─────────────── . ──── Signature ────
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 | トークンの対象者 |
jti | JWT ID | トークンの一意識別子(無効化の管理に使う) |
アクセストークンとリフレッシュトークン
JWTを使った認証では、2種類のトークンを使い分けるのが一般的です。
トークンのリフレッシュフロー:
アクセストークンの有効期限を短くすることで、万が一トークンが漏洩しても被害を最小限に抑えられます。しかし、短い有効期限だけではユーザー体験が悪化するため、リフレッシュトークンを使って透過的にアクセストークンを更新します。
Sanctum vs JWT の使い分け
| 観点 | Laravel Sanctum | JWT(tymon/jwt-auth) |
|---|---|---|
| トークン形式 | ランダム文字列(DBに保存) | 自己完結型JWT |
| 状態管理 | ステートフル(DB照会必要) | ステートレス(署名検証のみ) |
| 検証コスト | リクエストごとにトークンを1件DB照会 | 署名検証のみでDB照会なし |
| トークン無効化 | 即座に可能 | 有効期限まで無効化困難 |
| 適したケース | Laravelと同一ドメインのSPA・モバイル、ファーストパーティAPI | 複数サービスでトークンを共有する構成 |
| セットアップ | Laravel標準 | 追加パッケージ必要 |
- Sanctum: Laravelと同一ドメインのSPAや、トークンの即時無効化が必要な場合
- JWT: 複数サービス間での認証共有、高いスケーラビリティが必要な場合
- Passport: OAuth2 の完全サポート(認可コードフロー、サードパーティクライアントへのトークン発行)が必要な場合。Laravel公式の選定基準は「OAuth2 が要るか」であり、規模ではありません
本書では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 App\Enums\Role;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Tymon\JWTAuth\Contracts\JWTSubject;
class User extends Authenticatable implements JWTSubject
{
use HasFactory;
// roleは$fillableに入れない。認可の関心の列をリクエスト由来の値で埋めさせない
// という第13章と同じ扱いで、権限の自己申告を構造で塞ぐ
protected $fillable = [
'name',
'email',
'password',
];
protected $hidden = [
'password',
];
protected $casts = [
'role' => Role::class,
];
/**
* JWTの識別子(通常はプライマリキー)
*
* JWTSubject 側は戻り値型を宣言していないため、実装側で型を付けられる。
* プライマリキーの型に合わせて int|string に絞ることで型安全性を高める。
*/
public function getJWTIdentifier(): int|string
{
return $this->getKey();
}
/**
* JWTに含めるカスタムクレーム
*
* @return array<string, mixed>
*/
public function getJWTCustomClaims(): array
{
return [
'role' => $this->role->value,
];
}
}
認証コントローラの実装
以下のコードで使用するLoginRequestとRegisterRequestは、第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 = new User();
$user->fill([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);
// 初期ロールは固定値を直接代入する。省くと $this->role が null のまま
// JWTのクレーム生成に入る
$user->role = 'user';
$user->save();
$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'),
// 非対称鍵(RS256/ES256 系)を使う場合のみ設定する
// HS256 では 'secret'(.env の JWT_SECRET)だけを使うため空でよい
'keys' => [
'public' => env('JWT_PUBLIC_KEY'),
'private' => env('JWT_PRIVATE_KEY'),
'passphrase' => env('JWT_PASSPHRASE'),
],
// 必須クレーム
'required_claims' => [
'iss', // 発行者
'iat', // 発行時刻
'exp', // 有効期限
'nbf', // 有効開始時刻
'sub', // 対象(ユーザーID)
'jti', // トークンID(一意識別子)
],
];
RS256 に切り替えるには、鍵ペアを用意して JWT_PUBLIC_KEY / JWT_PRIVATE_KEY にファイルパスを設定し、JWT_ALGO=RS256 にします。php artisan jwt:secret が生成するのは HS256 用の対称鍵なので、RS256 では使いません。
DDDにおける認可設計
認可の責務分担
認可は、プレゼンテーション境界の Policy で「誰が何をできるか」(WHO) を判定し、ドメイン層で「その操作が業務上許されるか」(WHAT) を判定します。
UseCaseでの認可実装
UseCaseに渡す CancelOrderCommand は第11章「ユースケース層」で定義済みです(orderId と reason の 2 フィールド)。認可はこのCommandに含めません。「誰がキャンセルできるか」はControllerの Gate::authorize() が判定済みだからです。
// 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;
final class CancelOrderUseCase
{
public function __construct(
private readonly OrderRepositoryInterface $orderRepository,
) {}
/**
* @throws OrderNotFoundException
*/
public function execute(CancelOrderCommand $command): void
{
$orderId = new OrderId($command->orderId);
$order = $this->orderRepository->findById($orderId)
?? throw new OrderNotFoundException($orderId);
// 状態のルール(発送済みはキャンセル不可)は cancel() の中で判定される
$order->cancel($command->reason);
$this->orderRepository->save($order);
}
}
ドメイン層が持つのは状態のルールだけ
第6章「エンティティ」の Order は注文者を保持しません。誰の注文かは認可の関心で、orders.user_id カラムを OrderModel 経由で見る Policy が判定します(第12章)。ドメイン層が持つのは「この状態からキャンセルできるか」だけです。
その判定は OrderStatus::canBeCancelled()(第6章)が持ち、Order::cancel() がそれを呼んで違反時に例外を投げます。UseCaseは $order->cancel($command->reason) を呼ぶだけで、状態のルールを自分で書く必要はありません。
認可を拒否したときのレスポンス
Policyが false を返すと Gate::authorize() は例外を投げ、403 が返ります。第16章「エラーハンドリング」の bootstrap/app.php で、この例外を他のエラーと同じ error / code 形式に揃えてください。
ロールによる分岐はPolicyに置く
管理者だけが全注文を操作できる、のようなロール分岐も「誰が何をできるか」なので Policy の担当です。User モデルの $casts が参照する Role は次のように定義します。
// app/Enums/Role.php
namespace App\Enums;
enum Role: string
{
case USER = 'user';
case ADMIN = 'admin';
}
users.role カラムは第14章「ドメインモデルとテーブル設計」のマイグレーションで追加します。管理者バイパスは OrderPolicy の before() に置き、各メソッドにロール判定を散らしません。実装は第12章の OrderPolicyにあります。
Controllerでのユーザー情報取得
認証済みユーザーは auth()->user() で取得できます。ただしキャンセルAPIのControllerは第12章「プレゼンテーション層」に一本化してあります。Controllerは Gate::authorize('cancel', $order) で認可を通したあと、CancelOrderRequest から作ったCommandをUseCaseに渡すだけです。上のルーティング設定で登録した /{id}/cancel の実装がそれです。
セキュリティに関する注意点
JWTのセキュリティベストプラクティス
| 項目 | 推奨事項 |
|---|---|
| 有効期限 | アクセストークンは短く(15分〜1時間) |
| 署名アルゴリズム | RS256(非対称鍵)を推奨。HS256の場合は秘密鍵を厳重に管理し、人が覚えられるパスワードを鍵に使わない(php artisan jwt:secret が生成する乱数を使う) |
| ペイロード | 機密情報(パスワード、個人情報)を含めない |
| HTTPS | 必ずHTTPSで通信(トークンの盗聴防止) |
| リフレッシュトークン | HttpOnly Cookieに保存、DB保存時はハッシュ化 |
| 保存先 | アクセストークンはメモリに置く。localStorage はJavaScriptから常に読めるため、XSS 1 件で全トークンが漏れる |
| アルゴリズムの固定 | 検証時に受け入れるアルゴリズムをサーバ側で固定する。トークンの alg ヘッダの値をそのまま使わない |
| iss / aud の検証 | config/jwt.php の required_claims はクレームの存在しか確認しない。発行者や対象者を絞るなら、値の照合を自分で実装する |
クレーム検証の詳細は RFC 8725 - JSON Web Token Best Current Practices の 3 章にまとまっています。
トークン無効化(ブラックリスト)
JWTはステートレスなため、即座の無効化が困難です。必要な場合は以下の方法を検討してください。
ブラックリストは既定で有効です(config/jwt.php)。
// config/jwt.php(抜粋)
'blacklist_enabled' => env('JWT_BLACKLIST_ENABLED', true),
この状態でログアウトすると、そのトークンがブラックリストに入り以降は使えなくなります。
auth()->logout(); // 内部でブラックリストに追加される
ブラックリストはキャッシュ(Redis等)に保存されます。スケーラビリティを重視する場合は、アクセストークンの有効期限を短くし、ブラックリストを使わない設計も検討してください。
まとめ
| ポイント | 説明 |
|---|---|
| 認証 vs 認可 | 認証は「誰か」、認可は「何ができるか」 |
| 認証の層 | プレゼンテーション層(ミドルウェア) |
| 認可の層 | プレゼンテーション層(Policy) |
| ビジネスルールの層 | ドメイン層(Entity / Value Object) |
| JWT構造 | Header.Payload.Signature |
| アクセストークン | 短い有効期限(15分〜1時間) |
| リフレッシュトークン | 長い有効期限、HttpOnly Cookieに保存 |
| 認可の実装 | Policy で所有者・ロールを判定し、ドメイン層で状態を判定 |
参考リソース
- RFC 7519 - JSON Web Token (JWT) - JWT公式仕様
- RFC 8725 - JWT Best Current Practices - JWT実装時の必須確認事項
- JWT.io - JWTのデコード・検証ツール
- tymon/jwt-auth Documentation - Laravel JWT認証パッケージ
- Auth0 - Refresh Tokens - リフレッシュトークンのベストプラクティス
- Laravel公式ドキュメント - Authentication
- Laravel公式ドキュメント - Authorization
次のチャプターでは、これまで学んだ知識を統合して、注文システムを実際に実装していきます。