認証 — Sanctum によるトークン認証
ここまでに作った API は、誰でも叩けます。
第1章で挙げた 4 つの問題のうち、この章が扱うのは「誰でも叩ける」です。注文を作るのに認証が要らないので、他人のメールアドレスを書けば他人名義の注文を作れます。残る 2 つ (在庫の二重引き当てとメールの同期送信) は第14章以降で扱います。
この章で会員機能を入れ、次章で「誰が何をしてよいか」を決めます。
認証と認可は別のもの
先に用語を分けておきます。混同すると、次章の設計がぼやけます。
| 問い | 失敗したときのコード | |
|---|---|---|
| 認証 (authentication) | あなたは誰か | 401 Unauthorized |
| 認可 (authorization) | あなたはこれをしてよいか | 403 Forbidden |
ログインしていない人が注文一覧を叩けば 401 です。ログイン済みの人が他人の注文を叩けば 403 です。前者は「誰か分からない」、後者は「誰かは分かるが権限がない」。
この章は認証だけを扱います。認可は次章です。
Sanctum を選ぶ理由
Laravel は API 認証のパッケージを 2 つ提供しています。公式の選定基準は明快です。
OAuth2 の完全なサポートが必要なら Passport を使う。SPA・モバイルアプリ・単純な API トークンの発行が目的なら Sanctum を使う。Sanctum は OAuth2 に対応していないが、はるかに単純である1。
この連載が作るのは自社の EC サイト向け API です。第三者のアプリケーションに認可を委譲する必要はないので、Sanctum を選びます。
Sanctum は第4章の php artisan install:api で既にインストールされています。追加の作業は要りません。
Laravel × DDD × クリーンアーキテクチャ実践ガイドの認証・認可設計 が JWT の構造・リフレッシュトークン・Sanctum との使い分けを 1 章かけて扱っています。方式選定を掘り下げたいときはそちらを参照してください。
会員機能を用意する
users テーブルは、第3章で php artisan migrate を実行したときに既に作られています。Laravel の標準マイグレーションに含まれているためです。
User モデルに HasApiTokens トレイトを足します2。
use Laravel\Sanctum\HasApiTokens;
#[Fillable(['name', 'email', 'password'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
}
#[Hidden(['password', ...])] が最初から付いていることに注目してください。第7章で「モデルを直接返さない」と決めたので Resource 経由で返しますが、二重の防御になっています。
casts() の 'password' => 'hashed' も重要です。代入した時点で自動的にハッシュ化されます。平文のまま保存する事故を防げます。
登録・ログイン・ログアウト
3 本のエンドポイントを作ります。
use App\Http\Controllers\AuthController;
Route::post('/auth/register', [AuthController::class, 'register']);
Route::post('/auth/login', [AuthController::class, 'login']);
Route::post('/auth/logout', [AuthController::class, 'logout'])->middleware('auth:sanctum');
ログアウトだけ auth:sanctum が付いています。誰のトークンを消すのかを知る必要があるためです。
登録
public function register(RegisterRequest $request)
{
$user = User::create($request->validated());
return response()->json([
'token' => $user->createToken('api', ['order:create', 'order:read'])->plainTextToken,
], 201);
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:100'],
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
'password' => ['required', 'string', 'min:8', 'confirmed'],
];
}
confirmed ルールは、password_confirmation フィールドと一致するかを見ます。
createToken() が返すオブジェクトの plainTextToken が、クライアントに渡すトークンです2。第 2 引数はこのトークンに許す操作の一覧で、ability と呼びます。この章の後半で扱います。
ログイン
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
public function login(LoginRequest $request)
{
$user = User::where('email', $request->validated('email'))->first();
if (! $user || ! Hash::check($request->validated('password'), $user->password)) {
throw ValidationException::withMessages([
'email' => 'メールアドレスまたはパスワードが正しくありません。',
]);
}
return response()->json([
'token' => $user->createToken('api', ['order:create', 'order:read'])->plainTextToken,
]);
}
メールアドレスとパスワードのどちらが間違っているかを区別していません。 「このメールアドレスは登録されていません」と返すと、どのアドレスが会員かを外部から調べられます。総当たりの前段階として使われるので、区別せずに同じメッセージを返します。
ログアウト
public function logout(Request $request)
{
$request->user()->currentAccessToken()->delete();
return response()->noContent();
}
currentAccessToken() は、いまのリクエストに使われたトークンを指します2。これを消すことで、そのデバイスだけがログアウトします。他のデバイスのトークンは残ります。
すべてのデバイスからログアウトさせたいなら、こう書きます。
$request->user()->tokens()->delete();
パスワードを変更したときは、こちらを呼ぶのが定石です。
ルートを保護する
トークンが必要なルートに auth:sanctum を付けます2。
Route::apiResource('products', ProductController::class)->only(['index', 'show']);
Route::post('/auth/register', [AuthController::class, 'register']);
Route::post('/auth/login', [AuthController::class, 'login']);
Route::middleware('auth:sanctum')->group(function () {
Route::post('/auth/logout', [AuthController::class, 'logout']);
Route::post('/orders', [OrderController::class, 'store']);
});
商品の閲覧は公開のまま、注文の作成は認証が必要になりました。
クライアントは、リクエストヘッダにトークンを載せます。
Authorization: Bearer 1|abcdefghijklmnopqrstuvwxyz0123456789
トークンが無い、または無効な場合は 401 が返ります。第9章で shouldRenderJsonWhen が効いているので、HTML のログイン画面ではなく JSON が返ります。
ゲスト注文を会員注文へ移す
ここが、この章で一番手を動かす部分です。
第5章から第10章まで、注文の顧客は customer_email で表してきました。会員機能が入ったので、認証したユーザーに紐づける形へ移します。
実務でもよくある移行です。ゲスト購入だけで始めたサービスに会員機能を足す場面と同じ構造なので、手順を追う価値があります。
(a) user_id カラムを足す
php artisan make:migration add_user_id_to_orders_table
public function up(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->foreignId('user_id')->nullable()->after('id')->constrained();
});
}
public function down(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->dropConstrainedForeignId('user_id');
});
}
nullable() にしているのは、既存の注文に user_id が無いからです。会員機能を入れる前に作られた注文は、customer_email しか持ちません。ここを NOT NULL にすると、マイグレーションが既存行で失敗します。
第3章で「マイグレーションは前進のみで設計する」と書きました。カラムの追加を nullable から始めるのは、その具体例です。
customer_email は残します。会員のメールアドレスが変わっても、注文時に使ったアドレスは記録として保つためです。
(b) リクエストから customer_email を外す
public function rules(): array
{
return [
'items' => ['required', 'array', 'min:1', 'max:20'],
'items.*.product_id' => ['required', 'integer', 'exists:products,id'],
'items.*.quantity' => ['required', 'integer', 'min:1', 'max:99'],
];
}
customer_email のルールを削りました。クライアントから受け取らなくなったからです。
これは第5章の練習問題で扱った total_amount と同じ判断です。サーバー側が知っている値は、クライアントから受け取りません。認証済みのユーザーが誰かはサーバーが知っているので、リクエストに書かせる必要がありません。
書かせたままにすると、他人のメールアドレスを指定できる穴が残ります。
第5章で書いた prepareForValidation も不要になるので、削除します。
(c) サービスクラスの引数を変える
public function place(User $user, array $items): Order
{
$this->stockChecker->assertEnough($items);
// ... 金額の計算は同じ ...
$order = Order::create([
'user_id' => $user->id,
'customer_email' => $user->email,
'status' => OrderStatus::Pending,
'total_amount' => $total,
]);
// ... 以下同じ ...
}
第 1 引数を string $customerEmail から User $user に変えました。customer_email はユーザーから取ります。
Order の #[Fillable] にも user_id を足しておきます。
#[Fillable(['user_id', 'customer_email', 'status', 'total_amount'])]
第3章で OrderItem の #[Fillable] から order_id を外しました。同じ所有関係のキーなのに、判断が逆に見えます。
違いは値の出どころです。
OrderItem の order_id は、$order->items()->create([...]) の形でリレーション側が入れます。#[Fillable] に書く必要がなく、書けばリクエスト由来の配列で別の注文にぶら下げられる余地が生まれます。
Order の user_id は、サービスクラスが $user->id から組み立てた配列で Order::create() に渡します。#[Fillable] に無ければ黙って捨てられます (第3章で触れた既定の挙動)。user_id が null の注文ができあがり、誰の注文か分からなくなります。
判断の基準は「所有関係のキーかどうか」ではなく、その値をどこが入れるかです。リレーション経由なら不要、配列で渡すなら必要です。
配列で渡す形が気になるなら、$user->orders()->create([...]) とリレーション経由にする手もあります。その場合は user_id を #[Fillable] から外せます。
コントローラも合わせます。
public function store(StoreOrderRequest $request, OrderPlacementService $service)
{
$order = $service->place($request->user(), $request->validated('items'));
return (new OrderResource($order))
->response()
->setStatusCode(201);
}
$request->user() が認証済みのユーザーを返します。auth:sanctum を通っているので、ここが null になることはありません。
(d) テストを直す
第10章で書いたテストは、customer_email を送っていました。認証が必要になったので、すべて 401 で落ちます。
これは良い兆候です。テストが振る舞いの変化を検出しています。第10章で「テストを消して green にしない」と書きました。ここでやるのは、新しい期待に合わせて書き換えることです。
書き換えは 2 種類あります。認証を足すだけで済むものと、確認していた対象そのものが消えたものです。
第10章の「メールアドレスが無いと 422 になる」は後者です。customer_email のルールを外したので、このテストが守っていた振る舞いは存在しなくなりました。消すのではなく、同じ目的 (必須項目が欠けたら 422) を果たす対象へ移します。
test('商品を指定しないと422になる', function () {
Sanctum::actingAs(User::factory()->create());
$this->postJson('/api/v1/orders', [])
->assertStatus(422)
->assertJsonValidationErrors(['items']);
});
「ルールが消えたからテストも消す」で終わらせると、バリデーションが機能していることを誰も確認しなくなります。守りたかったのは特定のフィールドではなく、入力の検証が働いていることでした。
(e) ファクトリに user_id を足す
第3章で作った OrderFactory は customer_email / status / total_amount を埋めていました。user_id が加わったので、こちらも直します。
use App\Models\User;
public function definition(): array
{
return [
'user_id' => User::factory(),
'customer_email' => fake()->unique()->safeEmail(),
'status' => OrderStatus::Pending,
'total_amount' => fake()->numberBetween(1000, 100000),
];
}
'user_id' => User::factory() と書くと、注文を作るときにユーザーも一緒に作られます。第3章の StockFactory で Product::factory() を使ったのと同じ形です。
特定のユーザーの注文を作りたいテストでは、上書きします。
Order::factory()->create(['user_id' => $user->id]);
次章のテストはこの形を多用します。ファクトリの更新を忘れると、テストで作った注文がすべて別々のユーザーのものになり、一覧のテストが期待どおりになりません。
use App\Models\User;
use Laravel\Sanctum\Sanctum;
test('注文を作成できる', function () {
Sanctum::actingAs(User::factory()->create());
$product = Product::factory()->create(['price' => 1200]);
Stock::factory()->create(['product_id' => $product->id, 'quantity' => 10]);
$response = $this->postJson('/api/v1/orders', [
'items' => [
['product_id' => $product->id, 'quantity' => 3],
],
]);
$response
->assertStatus(201)
->assertJsonPath('data.total_amount', 3600);
});
Sanctum::actingAs() が、そのユーザーとして認証済みの状態を作ります2。実際にログイン API を叩く必要はありません。
そして、認証が要ることを確認するテストを足します。
test('未認証では注文を作成できない', function () {
$product = Product::factory()->create();
Stock::factory()->create(['product_id' => $product->id, 'quantity' => 10]);
$response = $this->postJson('/api/v1/orders', [
'items' => [
['product_id' => $product->id, 'quantity' => 1],
],
]);
$response->assertStatus(401);
$this->assertDatabaseCount('orders', 0);
});
保護したことをテストで固定します。 ミドルウェアの指定を誤って外したとき、このテストが落ちます。
トークンの権限を絞る
1 人のユーザーが複数のトークンを持てます。用途ごとに発行し、できることを絞れます2。
$user->createToken('mobile-app', ['order:create', 'order:read']);
$user->createToken('readonly-integration', ['order:read']);
第 2 引数が ability (権限) の一覧です。ルート側で確認します。次は書き方の例です。
Route::post('/orders', [OrderController::class, 'store'])
->middleware(['auth:sanctum', 'abilities:order:create']);
readonly-integration のトークンでこのエンドポイントを叩くと 403 になります。
これは第12章で扱う認可とは別の層です。ability は「このトークンで何ができるか」、Policy は「このユーザーがこのデータに対して何ができるか」を決めます。両方が通って初めてアクセスできます。
createToken('api') のように第 2 引数を省略すると、ability は ['*'] になります。すべての ability を持つ、という意味です。上のようなミドルウェアを書いても、そのトークンは必ず通ります。
だから登録とログインでは ['order:create', 'order:read'] を明示しています。発行側を絞らないかぎり、ルート側の確認は意味を持ちません。
上のルートの例は書き方を示すためのもので、実際に注文のルートへ足すわけではありません。このガイドで ability の確認を掛けるのは、第19章の商品画像アップロードの 1 本だけです。理由は同章で扱います。
有効期限と失効
Sanctum のトークンは、既定では期限がありません。 一度発行したトークンは、明示的に消すまで有効です。
期限を設定します。
'expiration' => 525600, // 分単位。525600 分 = 1 年
トークンごとに指定する方法もあります2。
$user->createToken('temporary', ['order:read'], now()->plus(weeks: 1))->plainTextToken;
期限切れのトークンは認証に使えなくなりますが、レコードはテーブルに残ります。放置すると personal_access_tokens テーブルが増え続けるので、定期的に消します。
php artisan sanctum:prune-expired --hours=24
これを毎日実行する設定は、第20章のタスクスケジューラで扱います。
本番で効く注意点
平文のトークンは発行時にしか見られない
createToken() が返す plainTextToken は、その場でしか取得できません。データベースにはハッシュ化された値が保存されます。
利用者が「トークンを忘れた」と言ってきても、こちらから教えられません。再発行するしかありません。この仕様は、データベースが漏れたときにトークンが使われないための設計です。
クライアント側は、受け取ったトークンを安全な場所に保存する必要があります。
トークンをログに出さない
Log::info('ログイン成功', ['token' => $token->plainTextToken]);
ログにトークンが残ると、ログを見られる人が全員そのユーザーになりすませます。ログは開発者以外にも共有されることがあり、外部の監視サービスへ送られることもあります。
第21章でログの設計を扱うときに、機密情報をマスクする方法を扱います。
ログイン試行に回数制限をかける
いまのログイン API は、何回でも試せます。パスワードの総当たりに対して無防備です。
第13章でレート制限を入れます。この章の時点では穴が空いていることを認識しておいてください。
自社の SPA には API トークンを使わない
Sanctum には、cookie を使った SPA 向けの認証方式もあります。同一ドメインで動く自社のフロントエンドには、そちらが向きます。
API トークンを JavaScript から扱うと、保存場所の選択が難しくなります。localStorage に置けば XSS で盗まれ、cookie に置くなら結局 cookie ベースの仕組みを自作することになります。
この連載は「フロントエンドは別で作る」前提で、モバイルアプリや外部連携も想定してトークン方式を採っています。自社 SPA だけが相手なら、公式ドキュメントの SPA 認証の節を確認してください。
まとめ
- 認証は「あなたは誰か」(401)、認可は「あなたはこれをしてよいか」(403)。この章は認証だけ
- OAuth2 が要らないなら Sanctum。公式がそう定めている
- ログインの失敗メッセージでメールアドレスとパスワードを区別しない。会員かどうかを調べられる
- サーバーが知っている値はクライアントから受け取らない。
customer_emailをリクエストから外したのはこの判断 - カラムの追加は
nullableから始める。既存行があるとマイグレーションが失敗する - 認証を入れたら「未認証で 401 になる」テストも書く。保護を外したときに気づける
- トークンは既定で無期限。
expirationを設定し、sanctum:prune-expiredで掃除する
次に読む
次章 認可 — Gate と Policy で「誰が何をできるか」を決める では認可を扱います。この章でログインできるようになりましたが、ログインさえすれば他人の注文も見られる状態です。自分の注文だけを返す一覧 API を作りながら、Gate と Policy の使い分け、そして「一覧では Policy が効かない」という落とし穴を扱います。
練習問題
次のログイン API には、外部から会員情報を調べられる問題があります。指摘してください
public function login(LoginRequest $request)
{
$user = User::where('email', $request->validated('email'))->first();
if (! $user) {
throw ValidationException::withMessages([
'email' => 'このメールアドレスは登録されていません。',
]);
}
if (! Hash::check($request->validated('password'), $user->password)) {
throw ValidationException::withMessages([
'password' => 'パスワードが正しくありません。',
]);
}
return response()->json(['token' => $user->createToken('api')->plainTextToken]);
}
解答例
エラーメッセージが 2 種類に分かれています。 これで会員かどうかを判定できます。
適当なパスワードで任意のメールアドレスを送ると、返るメッセージが分岐します。
- 「このメールアドレスは登録されていません」→ 未登録
- 「パスワードが正しくありません」→ 登録済み
手元にメールアドレスのリストがあれば、どれがこのサービスの会員かを片端から調べられます。会員であることが分かれば、そのアドレスに絞ってパスワードの総当たりを仕掛けられます。他のサービスから漏洩したパスワードの使い回しを試す攻撃 (クレデンシャルスタッフィング) でも、対象を絞る材料になります。
対策は、どちらの失敗でも同じメッセージを返すことです。
if (! $user || ! Hash::check($request->validated('password'), $user->password)) {
throw ValidationException::withMessages([
'email' => 'メールアドレスまたはパスワードが正しくありません。',
]);
}
利用者にとっては不親切に見えますが、ログインできない理由を正確に伝えることの価値より、会員リストを守る価値のほうが高いという判断です。
なお、同じ配慮が会員登録側にも要ります。unique:users,email で 422 を返すと、そのアドレスが登録済みだと分かります。厳密にやるなら、登録は常に 200 を返し、既存アドレスなら「すでに登録されています」というメールを送る方式にします。この連載では実装しませんが、判断が要る箇所として認識しておいてください。
customer_email をリクエストから外さず、認証済みユーザーのメールアドレスと一致するかを検証する方式ではどうでしょうか
解答例
動きますが、余計な失敗経路を作ります。
// この方式のルール
'customer_email' => ['required', 'email', function ($attribute, $value, $fail) {
if ($value !== auth()->user()->email) {
$fail('メールアドレスが一致しません。');
}
}],
確かに他人名義の注文は防げます。しかし考えてみると、クライアントは自分のメールアドレスを送り、サーバーはそれが正しいかを確認して、正しければ使うという流れです。サーバーが既に知っている値を、往復させているだけです。
この方式には具体的な不都合があります。
会員がメールアドレスを変更した直後に失敗します。 クライアントがキャッシュした古いアドレスを送ると、検証に落ちます。利用者から見れば「注文しようとしたらエラーになった」だけで、原因が分かりません。
クライアントの実装が増えます。 メールアドレスを保持し、変更時に更新する処理が必要になります。送らなくてよいなら、その処理そのものが不要です。
検証を書き忘れると穴が開きます。 ルールが 1 行消えるだけで、他人名義の注文が通ります。そもそも受け取らなければ、書き忘れる余地がありません。
受け取らない設計は、防御をコードの正しさに依存させません。 これが「サーバーが知っている値をクライアントから受け取らない」を原則として扱う理由です。第5章の total_amount、第7章の「公開するものを列挙する」と同じ姿勢です。