LaravelのミドルウェアでIP制限機能

Laravel

業務系のシステムなどではセキュリティを高めるために許可されたIPアドレスからのリクエストしか受け付けないようにするIP制限の機能を求められることがあります。

例えばAWSを使っているのであればAWS WAFで設定可能で、設定も楽でありできるだけネットワークの外側で制限したほうが良しとされています。

IP制限を行う方法はいくつかありますが、今回はアプリケーション側で許可されていないIPアドレスからのリクエストは受け付けない方法を説明します。

アプリケーション側で制限するメリットとしてはユーザーによってIP制限の有無を変更できたり、許可するIPアドレスも動的に変えることが可能になります。

環境

PHP 8.1
Laravel 8.*

今回は下記のような仕様があったと想定して話を進めたいと思います。

会社はリクエストを許可するIPアドレスを設定することができ、その会社の従業員は許可されたIPアドレスからしかアクセスできないようにする。

イメージER図

ミドルウェアの作成

まずはIPアドレスのチェックを行うミドルウェアを作成します。

php artisan make:middleware CheckIp

Laravel 8.x ミドルウェア

ミドルウェアはHTTPリクエストの検査やフィルタリングするためによく使われます。

<?php
declare(strict_types=1);

namespace App\Http\Middleware;

use Closure;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\IpUtils;

class CheckIp
{
    /**
     * Handle an incoming request.
     *
     * @param Request $request
     * @param Closure(\Illuminate\Http\Request): (\Illuminate\Http\Response|\Illuminate\Http\RedirectResponse)  $next
     * @return Response|RedirectResponse
     * @throws AuthorizationException
     */
    public function handle(Request $request, Closure $next)
    {
        // ①
        if (!App::isProduction()) {
            return $next($request);
        }
        $company = Auth::user()->company;
        // ②
        $allowedIps = $company->ips->pluck('ip')
            ->all();
        // ③
        if (empty($allowedIps)) {
            return $next($request);
        }
        // ④
        if (! IpUtils::checkIp($request->ip(), $allowedIps)) {
            throw new AuthorizationException(sprintf('%sは許可されていないIPアドレスです', $request->ip()));
        }
        // ⑤
        return $next($request);
    }
}

①環境によってIPアドレスのチェックを行うか判定
ローカル環境などで挙動を確認するときに毎回ローカルのIPアドレスを登録するのは面倒なので本番環境のみIP制限を有効にする条件分岐を入れています。

ここはローカル環境だけ実施しないなど状況によって適宜変更可能です。

②従業員が所属する会社のIPアドレスを取得
ログインしている従業員の会社をリレーションで取得して会社に紐づくIPアドレスを取得します。

③会社に紐づくIPアドレスが存在するかを判定
会社に紐づくIPアドレスが存在しない場合はリクエストを許可します。

④リクエスト元のIPアドレスが会社に紐づくIPアドレスに含まれるかを判定
細かい判定はIpUtils::checkIpが行ってくれます。
IpUtils::checkIpを使うとIPv6やサブネットマスクまで含めて判定してくれるので便利です。
symfony/http-foundation/IpUtils.php
例)0.0.0.0/24が登録されている場合、0.0.0.0〜0.0.0.255の256個のIPアドレスが範囲になるため、リクエストIPアドレスが0.0.0.10でも0.0.0.222でも許可されることになります。

IpUtils::checkIpについて中でどういったことをしているかは後述しています。

⑤許可されたIPアドレスであればリクエストを許可

カーネルにミドルウェアを登録

ミドルウェアを作成しただけではIP制限はできないのでカーネルにミドルウェアを登録します。

app/Http/Kernel.php$routeMiddlewareにCheckIpミドルウェアを追加します。

/**
 * The application's route middleware.
 *
 * These middleware may be assigned to groups or used individually.
 *
 * @var array<string, class-string|string>
 */
protected $routeMiddleware = [
    'auth' => Authenticate::class,
    'auth.basic' => AuthenticateWithBasicAuth::class,
    'cache.headers' => SetCacheHeaders::class,
    'can' => Authorize::class,
    'guest' => RedirectIfAuthenticated::class,
    'password.confirm' => RequirePassword::class,
    'signed' => ValidateSignature::class,
    'throttle' => ThrottleRequests::class,
    'verified' => EnsureEmailIsVerified::class,
    'check_ip' => CheckIp::class, // <= 追加
];

今回は$routeMiddlewareに登録しましたが、要件によって$middleware$middlewareGroupに登録することも可能です。

ルーティングに設定

$routeMiddlewareに追加した場合、どのルーティングに対してIP制限をかけるか指定する必要があります。

Route::middleware('auth:web')->group(function () {
    Route::middleware('check_ip')->group(function () {
        Route::get('/', function () {
            return view('home');
        });
    });
});

今回の例だとログインユーザーの所属会社IPアドレスを取得するため、authミドルウェアを通過した後にIP制限を行います。

グルーピングしてIP制限しても良いですし、特定のルーティングにだけ適応することも可能です。

おまけ IpUtils::checkIpの中身

便利にIPアドレスのチェックを行ってくれているIpUtils::checkIpですが、中で何をしているのかを少し覗いてみます。

<?php

/*
 * This file is part of the Symfony package.
 *
 * (c) Fabien Potencier <fabien@symfony.com>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Symfony\Component\HttpFoundation;

/**
 * Http utility functions.
 *
 * @author Fabien Potencier <fabien@symfony.com>
 */
class IpUtils
{
    private static $checkedIps = [];

    /**
     * This class should not be instantiated.
     */
    private function __construct()
    {
    }

    /**
     * Checks if an IPv4 or IPv6 address is contained in the list of given IPs or subnets.
     *
     * @param string|array $ips List of IPs or subnets (can be a string if only a single one)
     *
     * @return bool
     */
    public static function checkIp(?string $requestIp, $ips)
    {
        if (null === $requestIp) {
            trigger_deprecation('symfony/http-foundation', '5.4', 'Passing null as $requestIp to "%s()" is deprecated, pass an empty string instead.', __METHOD__);

            return false;
        }

        if (!\is_array($ips)) {
            $ips = [$ips];
        }

        $method = substr_count($requestIp, ':') > 1 ? 'checkIp6' : 'checkIp4';

        foreach ($ips as $ip) {
            if (self::$method($requestIp, $ip)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Compares two IPv4 addresses.
     * In case a subnet is given, it checks if it contains the request IP.
     *
     * @param string $ip IPv4 address or subnet in CIDR notation
     *
     * @return bool Whether the request IP matches the IP, or whether the request IP is within the CIDR subnet
     */
    public static function checkIp4(?string $requestIp, string $ip)
    {
        if (null === $requestIp) {
            trigger_deprecation('symfony/http-foundation', '5.4', 'Passing null as $requestIp to "%s()" is deprecated, pass an empty string instead.', __METHOD__);

            return false;
        }

        $cacheKey = $requestIp.'-'.$ip;
        if (isset(self::$checkedIps[$cacheKey])) {
            return self::$checkedIps[$cacheKey];
        }

        if (!filter_var($requestIp, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV4)) {
            return self::$checkedIps[$cacheKey] = false;
        }

        if (str_contains($ip, '/')) {
            [$address, $netmask] = explode('/', $ip, 2);

            if ('0' === $netmask) {
                return self::$checkedIps[$cacheKey] = filter_var($address, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV4);
            }

            if ($netmask < 0 || $netmask > 32) {
                return self::$checkedIps[$cacheKey] = false;
            }
        } else {
            $address = $ip;
            $netmask = 32;
        }

        if (false === ip2long($address)) {
            return self::$checkedIps[$cacheKey] = false;
        }

        return self::$checkedIps[$cacheKey] = 0 === substr_compare(sprintf('%032b', ip2long($requestIp)), sprintf('%032b', ip2long($address)), 0, $netmask);
    }
    // ...省略
}

※IPv6のメソッドも記述されていましたが今回は割愛します。

checkIp()では第一引数で渡されたリクエストIPに:(コロン)があるかどうかでIPv4かIPv6かを判定して呼び出すメソッドを切り分けていますね。

$method = substr_count($requestIp, ':') > 1 ? 'checkIp6' : 'checkIp4';

IPv6がどういうものかについてはこちらをご参照ください。

checkIp4()では

  1. リクエストIPアドレスがIPアドレスの型であるかフィルタリング
  2. 検証するIPアドレスにサブネットが含まれる場合はIPアドレスの部分とサブネットで分割
  3. リクエストIPアドレスと検証IPアドレスを2進数の32文字に変換
  4. 2進数に変換した2つの値を比較

1 リクエストIPアドレスがIPアドレスの型であるかフィルタリング

if (!filter_var($requestIp, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV4)) {
    return self::$checkedIps[$cacheKey] = false;
}

2 検証するIPアドレスにサブネットが含まれる場合はIPアドレスの部分とサブネットで分割
サブネットがなければ固定で32になるようになっています。

[$address, $netmask] = explode('/', $ip, 2);

3 リクエストIPアドレスと検証IPアドレスを2進数の32文字に変換

sprintf('%032b', ip2long($requestIp)), sprintf('%032b', ip2long($address))

4 2進数に変換した2つの値を比較
合っていればtrueを返す。

return self::$checkedIps[$cacheKey] = 0 === substr_compare(sprintf('%032b', ip2long($requestIp)), sprintf('%032b', ip2long($address)), 0, $netmask);

サブネットを含めて検証するときは2進数に変換してから確認すれば良いんですね!

Laravelのソースコード読むと勉強になります。

参考

コメント

タイトルとURLをコピーしました