LaravelでIP制限機能の実装

はじめに

こんにちは、株式会社ROXXの開発責任者の小平(@ryotakodaira )です。 業務では、SARDINEという人材紹介会社向けの業務管理システムを開発・運用をしています。

規模の大きい人材紹介会社がSARDINEを利用するにあたって、システムの利用時に 自社のIPからのみアクセスを許可 したいという開発案件が発生したため、備忘録的に開発としてどのような対応を行ったのかを残そうと思います。

準備

今回の開発で達成したいこととしては、

「システムの利用時に 自社のIPからのみアクセスを許可 したい」となっており、

ユーザー毎に自社のIPアドレスを設定できるようにすることでした。

当然ですが、認証を先に済ませないとどのIPアドレスを許可するかの判断をシステム側で行うことができないため、認証済みのリクエストが来たときに必ずIP制限の評価が走るミドルウェアを実装することとしました。

早速、ミドルウェアを作っていきます。

Laravelのartisanコマンドでミドルウェアクラスのファイルを作ることができるので、そちらを利用します。

$ php artisan make:middleware CustomIpLimitation

ミドルウェアの実装

<?php

namespace App\Http\Middleware;

use App\Entities\User;
use Closure;
use Symfony\Component\HttpFoundation\IpUtils;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;

/**
 * Class CustomIpLimitation
 * @package App\Http\Middleware
 */
class CustomIpLimitation
{
    /**
     * @var array
     */
    // ①
    const ACCEPTED_IPS_GROUP_BY_USER = [
        // user_id => ['127.0.0.1/0', '127.0.0.1/1']
        1 => [
            '127.1.1.1/32',
            '127.2.2.2/32',
        ],
    ];

    /**
     * @see https://ip-ranges.amazonaws.com/ip-ranges.json
     */
    // ②
    const CF_IPS = [
        // CloudFrontのIPレンジ
        '13.124.199.0/24',
    ];

    /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request $request
     * @param  \Closure $next
     * @return mixed
     */
    public function handle($request, Closure $next)
    {
        // ③
        if (!app()->runningUnitTests() && !app()->environment('production')) {
            return $next($request);
        }

        /** @var User $user */
        $user = $request->user();

        // ④
        $allowedIps = $this->allowedIps4AuthenticatedUser($user->id);

        // ⑤
        if (empty($allowedIps)) {
            return $next($request);
        }

        // ⑥
        $request::setTrustedProxies(
            [$request->server->get('REMOTE_ADDR')] + self::CF_IPS,
            $request::HEADER_X_FORWARDED_AWS_ELB
        );
        $clientIp = $request->ip();

        // ⑦
        if (!IpUtils::checkIp($clientIp, $allowedIps)) {
            throw new AccessDeniedHttpException('IPNotAllowed');
        }

        // ⑧
        return $next($request);
    }

    /**
     * @param int $userId
     * @return array
     */
    protected function allowedIps4AuthenticatedUser(int $userId): array
    {
        return self::ACCEPTED_IPS_GROUP_BY_USER[$userId] ?? [];
    }
}

実装内容について順を追って説明していきます。

ACCEPTED_IPS_GROUP_BY_USER という定数に、 IP制限を設定するユーザーIDをkey、許可したいIPアドレスの配列をvalue 、とした配列を定義します。

本来はこれらの情報は何らかのデータベースで永続化し、都度データベースからデータを引いてくるべきですが、本投稿では定数に定義する形で進めます。

CF_IPS という定数に、CloudFrontのIPレンジを設定しています。

弊社のサービスはCloudFrontを使用しているため後々の処理でCFのIPレンジが必要となります。

こちらも本来は定数として定義するのではなく、都度、以下のURLを参照するなどをして最新のIPレンジを取得するようにした方が良いでしょう。

https://ip-ranges.amazonaws.com/ip-ranges.json

(体感ですが、CFのIPレンジは1,2週間に1度くらいのペースでアップデートがかかります。)

こちらはあってもなくてもどちらでも良いですが、開発中にIP制限に引っかかってしまい非常に面倒だったため、開発中はこの機能を無視するようにしています。

phpunit実行時, 本番環境以外では機能を無視するようにしています。

で定義したIPリスト( ACCEPTED_IPS_GROUP_BY_USER )をクライアントからリクエストを送信したユーザーIDで検索して、そのユーザーIDに対してIP制限が設定されていた場合は、許可するIPアドレスの配列を返却しています。

で取得した許可するIPアドレスの配列が空の場合は、IP制限を設定していないものとみなしそのままミドルウェアの処理を抜けます。

クライアントのIPアドレスを取得する前に setTrustedProxies を行い、サービス提供者が直接管理しているリバースプロキシのリストをセットします。

セットすべきリバースプロキシの内容はサービスのインフラ構成により異なってきますが、弊社のサービスの場合は CF -> ALB -> EC2 となっているため、

self::CF_IPS で最初に定義したCFのIPレンジを取り、 $request->server->get('REMOTE_ADDR') で直前(ALB)のIPレンジを取ってそれらをセットしています。 ここは完全にインフラ構成に依存していますが、そのことを最初は考慮できていなかったため割と躓いたポイントです。

その後、 $request->ip() で正確なクライアントIPアドレスを取得することができます。

クライアントのIPアドレスが許可されたIPアドレスの範囲内にあるかを検証しています。

SymfonySymfony\Component\HttpFoundation\IpUtils::checkIp メソッドを使えば一発で評価することができます。

許可されたIPアドレスの範囲内になかった場合はその時点でエラーをクライアントに対して返却しています。

クライアントのIPアドレスが許可されたIPアドレスの範囲内であれば、正常とみなしミドルウェアを抜けて終了となります。

後は app/Http/Kernel.php などで今回作成したミドルウェアを通るように設定してあげれば完了です。

最後に

事業・サービスが成長していくにあたって、これからもメンバーを増やしていきたいと思っています。

新規事業や既存事業の拡大も考えているため自分の力で事業を成長させたいエンジニアを絶賛募集中です!

興味のある方は下記からご応募いただくか、@ryotakodairaにご連絡ください!!

www.wantedly.com

www.wantedly.com