Laravel ミドルウェアでIP制限をする

開発環境等、IPでアクセス制限をかけたい場合
例えばAWSを使っていればセキュリティグループでIP制限をかけたりします。

しかしIP制限をかけるエンドポイントを柔軟に変更したり、複雑な条件によってIP制限をかけたりしたい場合はLaravel側でIP制限をかけた方が良い場合があります。

## 環境

- PHP 8.0.10
- Laravel 8.58.0

## ミドルウェアを作成する

- [Laravel 8.x ミドルウェア](https://readouble.com/laravel/8.x/ja/middleware.html)

```shell-session
$ php artisan make:middleware Firewall
```

Laravelではリクエストの検証、フィルタリングする場合はミドルウェアという便利な機能が用意されています。
IP制限をかけるので名前は`Firewall`にしてみました。

```php:app/Http/Middleware/Firewall.php
<?php

declare(strict_types=1);

namespace App\Http\Middleware;

use Closure;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\IpUtils;

final class Firewall
{
    private const ALLOWED_IPS = [
        '127.0.0.1', // 例) ローカルからのアクセスは許可
        '172.17.0.1/16', // 例) サブネットマスクで範囲指定も可能(docker networkのIP range)
    ];

    /**
     * Handle an incoming request.
     *
     * @param Request $request
     * @param Closure $next
     * @return mixed
     * @throws AuthorizationException
     */
    public function handle(Request $request, Closure $next)
    {
        if (config('app.env') === 'local' || $this->isAllowedIp($request->ip())) {
            return $next($request);
        }

        throw new AuthorizationException(sprintf('Access denied from %s', $request->ip()));
    }

    /**
     * @param string $ip
     * @return bool
     */
    private function isAllowedIp(string $ip): bool
    {
        return IpUtils::checkIp($ip, self::ALLOWED_IPS);
    }
}
```

:::note info
ロードバランサーを経由している場合

`$request->ip();` だとロードバランサーのIPが取得されます。
`collect($request->getClientIps())->last();` でクライアントのIPを取得できます。
:::

許可するIPが固定値であれば、Firewallミドルウェアの定数として登録して良いと思います。
環境ごとに変えたい場合は、configファイルや環境変数を利用したり、データベースでIPを管理すると良いでしょう。

[`IpUtils::checkIp`](https://github.com/symfony/http-foundation/blob/5.3/IpUtils.php#L30-L52) を使うとサブネットマスクを含めて判定してくれるので便利です。

## Httpカーネルにミドルウェアを登録する

- [Laravel 8.x Httpカーネル](https://readouble.com/laravel/8.x/ja/lifecycle.html#lifecycle-overview)

```php:app/Http/Kernel.php
<?php declare(strict_types=1);

namespace App\Http;

use Illuminate\Foundation\Http\Kernel as HttpKernel;

class Kernel extends HttpKernel
{
    /**
     * The application's global HTTP middleware stack.
     *
     * These middleware are run during every request to your application.
     *
     * @var array
     */
    protected $middleware = [
        // \App\Http\Middleware\TrustHosts::class,
        \App\Http\Middleware\TrustProxies::class,
        \Fruitcake\Cors\HandleCors::class,
        \App\Http\Middleware\PreventRequestsDuringMaintenance::class,
        \Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
        \App\Http\Middleware\TrimStrings::class,
        \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
    ];

    /**
     * The application's route middleware groups.
     *
     * @var array
     */
    protected $middlewareGroups = [
        'web' => [
            \App\Http\Middleware\EncryptCookies::class,
            \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
            \Illuminate\Session\Middleware\StartSession::class,
            // \Illuminate\Session\Middleware\AuthenticateSession::class,
            \Illuminate\View\Middleware\ShareErrorsFromSession::class,
            \App\Http\Middleware\VerifyCsrfToken::class,
            \Illuminate\Routing\Middleware\SubstituteBindings::class,
        ],

        'api' => [
            // \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
            'throttle:api',
            \Illuminate\Routing\Middleware\SubstituteBindings::class,
        ],
    ];

    /**
     * The application's route middleware.
     *
     * These middleware may be assigned to groups or used individually.
     *
     * @var array
     */
    protected $routeMiddleware = [
        'auth' => \App\Http\Middleware\Authenticate::class,
        'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
        'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
        'can' => \Illuminate\Auth\Middleware\Authorize::class,
        'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
        'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
        'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class,
        'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
        'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
        'firewall' => \App\Http\Middleware\Firewall::class, // ここに追加
    ];
}
```

全体的に適用したい場合は、 `$middleware` に追加したり、 `$middlewareGroups` に追加してもokです。

## ルーティングにミドルウェアを設定

`routes/web.php` や `routes/api.php` で定義した `firewall` ミドルウェアを設定します。

```php:routes/web.php
<?php declare(strict_types=1);

use Illuminate\Support\Facades\Route;

Route::middleware(['firewall'])->group(function () {
    // IP制限したいエンドポイントをグループ内に定義する
    Route::get('/', function () {
        return view('welcome');
    });
});
```

## 動作確認

```:.env
APP_ENV=local
```

![ScreenShot 2021-09-05 22.15.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/52879/9607e788-507e-329b-dbd8-6ce75a7e01d6.png)

環境変数 `APP_ENV=local` の時はアクセス制限せず200レスポンスが返ります。
(ローカル環境でIP制限があるのは面倒ですからね...)

```:.env
APP_ENV=development
```

![ScreenShot 2021-09-05 22.14.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/52879/64a24bc0-d34a-e0a7-a086-dfee5b69bb88.png)

環境変数 `APP_ENV=development` の時は登録されてないIPでアクセスがあった場合403レスポンスを返してます。

ミドルウェアを使ってIP制限できるようになりました！
開発環境や本番環境、特定の画面だけでIP制限したい場合でも簡単にカスタマイズできると思うのでぜひ使ってみてください。

## 参考

- https://github.com/symfony/http-foundation/blob/5.3/IpUtils.php#L30-L52
- https://jkbys.net/docker%E3%81%8C%E8%87%AA%E5%8B%95%E3%81%A7%E5%89%B2%E3%82%8A%E5%BD%93%E3%81%A6%E3%82%8Bip%E3%82%A2%E3%83%89%E3%83%AC%E3%82%B9%E3%81%AE%E7%AF%84%E5%9B%B2%E3%82%92%E6%8C%87%E5%AE%9A