0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Laravel Feature Testでメール認証を検証する:署名付きURL・期限切れ・別ユーザー・認証済み分岐まで

0
Posted at

Laravel Breeze標準のメール認証を有効化する中で、EmailVerificationTest.phpへメール認証URLまわりのFeature Testを追加しました。

この記事では、既存の正常系テストを土台にしながら、次のケースを1つずつ追加した過程を整理します。

  • 署名なしURL
  • 期限切れ署名URL
  • 別ユーザー用の署名付きURL
  • 認証済みユーザーの認証案内画面アクセス
  • 認証済みユーザーの認証メール再送
  • 認証済みユーザーの認証リンク再訪

目次


環境

  • PHP 8.4
  • Laravel 13
  • Laravel Breeze
  • PHPUnit
  • Laravel Sail
  • MySQL

前提:Laravel Breezeのメール認証ルート

今回のアプリでは、Laravel Breezeが生成したメール認証ルートを利用しています。

※以下の3ルートは、このプロジェクトではauth middlewareグループ内に定義されています。

Route::get('verify-email', EmailVerificationPromptController::class)
    ->name('verification.notice');

Route::get('verify-email/{id}/{hash}', VerifyEmailController::class)
    ->middleware(['signed', 'throttle:6,1'])
    ->name('verification.verify');

Route::post('email/verification-notification', [EmailVerificationNotificationController::class, 'store'])
    ->middleware('throttle:6,1')
    ->name('verification.send');

認証URLにはsignedミドルウェアが付いています。そのため、idhashが正しいだけではなく、Laravelが生成した有効な署名付きURLであることも必要です。

参考:


既存テストで確認できていたこと

もともとのEmailVerificationTest.phpには、次のテストがありました。

email verification screen can be rendered
email verification notification can be resent with accessible status
email can be verified
email is not verified with invalid hash

つまり、認証案内画面・認証メール再送・正常なメール認証・不正hashまでは確認済みでした。


1. 署名なしURLでは認証できないことを確認する

今回わざと壊すのは署名だけです。

id   → 正しい
hash → 正しい
署名 → なし

未認証ユーザーを作ります。

$user = User::factory()->create([
    'email_verified_at' => null,
]);

通常のroute()で署名なしURLを作ります。

$verificationUrl = route('verification.verify', [
    'id' => $user->id,
    'hash' => sha1($user->email),
]);

idhashは正しい値にして、署名だけを欠落させます。

$response = $this->actingAs($user)->get($verificationUrl);

$response->assertForbidden();

$freshUser = $user->fresh();

$this->assertInstanceOf(User::class, $freshUser);
$this->assertFalse($freshUser->hasVerifiedEmail());

assertForbidden()は403 Forbiddenを確認します。fresh()はDBから最新のモデル状態を取り直すために使います。

fresh()は型上nullになり得るため、現在のテストではassertInstanceOf()Userモデルであることを確認してからhasVerifiedEmail()を呼び出しています。

完成したテストです。

/**
 * 改ざんされた認証URLによるメール認証を防ぐため、
 * 有効な署名がないURLは拒否され、未認証状態が維持されることを保証する。
 */
public function test_email_is_not_verified_without_valid_signature(): void
{
    $user = User::factory()->create([
        'email_verified_at' => null,
    ]);

    $verificationUrl = route('verification.verify', [
        'id' => $user->id,
        'hash' => sha1($user->email),
    ]);

    $response = $this->actingAs($user)->get($verificationUrl);

    $response->assertForbidden();

    $freshUser = $user->fresh();

    $this->assertInstanceOf(User::class, $freshUser);
    $this->assertFalse($freshUser->hasVerifiedEmail());
}

2. 期限切れ署名URLでは認証できないことを確認する

次は、署名自体は正しいものの有効期限が切れているURLを作ります。

temporarySignedRoute()は、有効期限付きの署名URLを生成します。

URL::temporarySignedRoute(
    'verification.verify',
    now()->addMinutes(60),
    [
        'id' => $user->id,
        'hash' => sha1($user->email),
    ]
);

今回はnow()->subMinute()を指定して、すでに期限切れのURLを作ります。

/**
 * 期限切れの認証URLによるメール認証を防ぐため、
 * 有効期限を過ぎた署名付きURLは拒否され、未認証状態が維持されることを保証する。
 */
public function test_email_is_not_verified_with_expired_signature(): void
{
    $user = User::factory()->create([
        'email_verified_at' => null,
    ]);

    $verificationUrl = URL::temporarySignedRoute(
        'verification.verify',
        now()->subMinute(),
        [
            'id' => $user->id,
            'hash' => sha1($user->email),
        ]
    );

    $response = $this->actingAs($user)->get($verificationUrl);

    $response->assertForbidden();

    $freshUser = $user->fresh();

    $this->assertInstanceOf(User::class, $freshUser);
    $this->assertFalse($freshUser->hasVerifiedEmail());
}

このテストでは、id・hash・署名は正しく、期限だけを失効させています。


3. 別ユーザー用の署名付きURLでは認証できないことを確認する

2人のユーザーを作ります。

$user = User::factory()->create([
    'email_verified_at' => null,
]);

$otherUser = User::factory()->create([
    'email_verified_at' => null,
]);

役割は次のとおりです。

$user      → 実際にログインしてURLを踏む人
$otherUser → 認証URLの本来の持ち主

$otherUser本人用の正しい署名付きURLを作ります。

$verificationUrl = URL::temporarySignedRoute(
    'verification.verify',
    now()->addMinutes(60),
    [
        'id' => $otherUser->id,
        'hash' => sha1($otherUser->email),
    ]
);

そのURLへ$userとしてアクセスします。

$response = $this->actingAs($user)->get($verificationUrl);

$response->assertForbidden();

$freshOtherUser = $otherUser->fresh();

$this->assertInstanceOf(User::class, $freshOtherUser);
$this->assertFalse($freshOtherUser->hasVerifiedEmail());

完成したテストです。

/**
 * 他ユーザー用の認証URLによるなりすまし認証を防ぐため、
 * 別ユーザーが有効な署名付きURLへアクセスしても拒否され、
 * URL本来の所有者が未認証状態のままであることを保証する。
 */
public function test_user_cannot_verify_email_with_another_users_signed_url(): void
{
    $user = User::factory()->create([
        'email_verified_at' => null,
    ]);

    $otherUser = User::factory()->create([
        'email_verified_at' => null,
    ]);

    $verificationUrl = URL::temporarySignedRoute(
        'verification.verify',
        now()->addMinutes(60),
        [
            'id' => $otherUser->id,
            'hash' => sha1($otherUser->email),
        ]
    );

    $response = $this->actingAs($user)->get($verificationUrl);

    $response->assertForbidden();

    $freshOtherUser = $otherUser->fresh();

    $this->assertInstanceOf(User::class, $freshOtherUser);
    $this->assertFalse($freshOtherUser->hasVerifiedEmail());
}

4. 認証済みユーザーは認証案内画面を再表示しない

$user = User::factory()->create();

このプロジェクトのUserFactoryでは通常作成したユーザーは認証済みです。

$response = $this->actingAs($user)->get('/verify-email');

$response->assertRedirect(RouteServiceProvider::HOME);

完成したテストです。

/**
 * 認証済みユーザーに不要な認証案内画面を表示しないため、
 * メール認証画面へアクセスした場合はHOMEへリダイレクトされることを保証する。
 */
public function test_verified_user_is_redirected_from_email_verification_screen(): void
{
    $user = User::factory()->create();

    $response = $this->actingAs($user)->get('/verify-email');

    $response->assertRedirect(RouteServiceProvider::HOME);
}

5. 認証済みユーザーへ認証メールを再送しない

Notification::fake()で本物の通知送信を止めます。

Notification::fake();

認証済みユーザーで再送ルートへPOSTします。

$response = $this
    ->actingAs($user)
    ->post(route('verification.send'));

認証済みなのでHOMEへ戻り、VerifyEmail通知も送られないことを確認します。

$response->assertRedirect(RouteServiceProvider::HOME);

Notification::assertNotSentTo(
    $user,
    VerifyEmail::class,
);

完成したテストです。

/**
 * 認証済みユーザーへ不要な認証メールを再送しないため、
 * 再送要求時はHOMEへリダイレクトされ、VerifyEmail通知が送信されないことを保証する。
 */
public function test_verified_user_does_not_receive_verification_notification_again(): void
{
    Notification::fake();

    $user = User::factory()->create();

    $response = $this
        ->actingAs($user)
        ->post(route('verification.send'));

    $response->assertRedirect(RouteServiceProvider::HOME);

    Notification::assertNotSentTo(
        $user,
        VerifyEmail::class,
    );
}

6. 認証済みユーザーが認証リンクを再訪してもVerifiedイベントを再発火しない

Event::fake()でイベントをテスト用にFakeへ差し替えます。

Event::fake();

認証済みユーザー本人用の正しい署名付きURLを作ります。

$verificationUrl = URL::temporarySignedRoute(
    'verification.verify',
    now()->addMinutes(60),
    [
        'id' => $user->id,
        'hash' => sha1($user->email),
    ]
);

そのリンクを再訪します。

$response = $this->actingAs($user)->get($verificationUrl);

$response->assertRedirect(RouteServiceProvider::HOME.'?verified=1');

Event::assertNotDispatched(Verified::class);

完成したテストです。

/**
 * 認証済みユーザーによる認証処理の重複実行を防ぐため、
 * 認証リンクを再訪してもVerifiedイベントが再発火しないことを保証する。
 */
public function test_verified_user_can_revisit_verification_link_without_dispatching_verified_event(): void
{
    $user = User::factory()->create();

    Event::fake();

    $verificationUrl = URL::temporarySignedRoute(
        'verification.verify',
        now()->addMinutes(60),
        [
            'id' => $user->id,
            'hash' => sha1($user->email),
        ]
    );

    $response = $this->actingAs($user)->get($verificationUrl);

    $response->assertRedirect(RouteServiceProvider::HOME.'?verified=1');

    Event::assertNotDispatched(Verified::class);
}

Notification::fake()Event::fake()の違い

今回のテストでは両方使いました。

メソッド 対象
Notification::fake() Notification
Event::fake() Event

今回なら、

VerifyEmail → Notification
Verified    → Event

です。

Notification::assertNotSentTo()ではメール認証通知の送信有無を確認し、Event::assertNotDispatched()では認証完了イベントの発火有無を確認します。

assertForbidden()assertFalse()を両方書く理由

不正URLテストでは、403だけで終わらずDBの認証状態も確認しました。

$response->assertForbidden();

$freshUser = $user->fresh();

$this->assertInstanceOf(User::class, $freshUser);
$this->assertFalse($freshUser->hasVerifiedEmail());

理由は、

HTTP上は拒否された
しかしDBの状態は変わっていた

という副作用を見逃さないためです。

アクセス制御やセキュリティのテストでは、レスポンスとDB状態の両方を見ると意図が明確になります。


今回覚えたLaravel / PHPUnitのテスト用メソッド

メソッド 役割
route() 名前付きルートからURLを生成する
URL::temporarySignedRoute() 有効期限付き署名URLを生成する
now()->addMinutes() 現在時刻より未来を指定する
now()->subMinute() 現在時刻より過去を指定する
actingAs() 指定ユーザーとしてログイン状態を作る
assertForbidden() 403 Forbiddenを確認する
fresh() DBから最新のモデル状態を取り直す
assertInstanceOf() 値が期待したクラスのインスタンスであることを確認する
hasVerifiedEmail() メール認証済みか確認する
Notification::fake() NotificationをFakeへ差し替える
Notification::assertNotSentTo() 指定Notificationが送られていないことを確認する
Event::fake() EventをFakeへ差し替える
Event::assertNotDispatched() 指定Eventが発火していないことを確認する

最終的なEmailVerificationTestの実行結果

sail artisan test tests/Feature/Auth/EmailVerificationTest.php

結果:

PASS  Tests\Feature\Auth\EmailVerificationTest

✓ email verification screen can be rendered
✓ email verification notification can be resent with accessible status
✓ email can be verified
✓ email is not verified with invalid hash
✓ email is not verified without valid signature
✓ email is not verified with expired signature
✓ user cannot verify email with another users signed url
✓ verified user is redirected from email verification screen
✓ verified user does not receive verification notification again
✓ verified user can revisit verification link without dispatching verified event

Tests: 10 passed (40 assertions)

正常系・不正URL・認証済みユーザーの分岐を含めて、10テストすべてPASSしました。

--

今回のまとめ

今回のFeature Test追加で、メール認証を単に「正しいURLなら認証できる」だけではなく、署名なし・期限切れ・別ユーザー用URL・認証済みユーザーの再操作まで確認できました。

特に重要だったのは、テストごとに1つだけ条件を変えることです。

署名なしテストなら、

idは正しい
hashも正しい
署名だけない

期限切れテストなら、

idは正しい
hashも正しい
署名も正しい
期限だけ切れている

という状態を作ります。

こうすることで、何が原因で拒否されたのかが明確になります。

また、不正アクセスを拒否したことだけでなく、DB上のメール認証状態が変わっていないこと、通知が再送されていないこと、イベントが再発火していないこともFeature Testで確認できました。


関連記事

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?