PHPで連続アクセスの制限（同一のIPアドレスから等）総当り対策、F5アタック対策、DoS攻撃対策？

## 概要
* 同一の識別文字列（例:IPアドレス）から10秒間に10回あれば60秒アクセス制限をする。
* 同一の識別文字列（例:IPアドレス）から60秒間に30回あれば300秒アクセス制限をする。
* DoS攻撃対策、総当り対策、F5アタック対策としてコピペするだけで動作する。（はず。私の環境が環境がXAMPPなので💧）
* 適切な関数名を募集しています。

> ** NOTE **
> DoS攻撃などを防ぐには、denyhostsやfail2banなどのミドルウェアやApacheとNginx等WEBサーバーでアクセス制限をしましょう。
> 本記事はミドルウェアレベルで設定ができない場合のアプリケーションレベルでの制限を目的としています。
> 
> 同一の識別文字列という事で、同じログインユーザーを使用し別々の場所(IP)から同時に過度なアクセスがあった時に、ログインユーザーに一時的な利用制限をするなどを想定しています。
> …というとユーザーマスタに項目を持たせた方が適切ですよね？という話が出てくるかもしれませんが、その通りだと思います。ただ、ユーザーコードに限定しているわけでもIPアドレスに限定しているわけでもなく「例え」ですので、アプリケーションの設計の話はご容赦くださいませ。
> 

## 動作を確認できるサイト

[やみ姐さん🔪の家](https://yamine1san.ddns.net/qiita/access-limit/index.php)
F5アタックすると確認できます。

## 処理概要

* 同一の識別文字列（例:IPアドレス）毎にデータファイルを作成
* 同一の識別文字列（例:IPアドレス）でアクセスがあるたびに現在のタイムスタンプを履歴に追加
* 履歴から10秒間のアクセス回数と60秒間のアクセス回数を取得
* 60秒間に30回あれば300秒アクセス制限
* 10秒間に10回あれば60秒アクセス制限

## ソースコード

### 使用方法

```php
if (isset($_SERVER) && array_key_exists('REMOTE_ADDR', $_SERVER) && '' !== $_SERVER['REMOTE_ADDR']) {
    access_is_restricted_according_to_the_number_of_accesses_from_the_same_IP_address_within_a_unit_time($_SERVER['REMOTE_ADDR'], 'ipv4');
}
```


### config

```php
class config {

    /**
     * 10秒以内にｎ回のアクセスがあった場合アクセス制限をする
     */
    const THRESHOLD_ACCESS_COUNT_WITHIN_10SECONDS = 10;

    /**
     * 10秒以内の連続アクセス違反の時のアクセス制限時間
     */
    const ACCESS_RESTRICTION_PERIOD_10 = '60 second';

    /**
     * 60秒以内にこの値の回数のアクセスがあった時はアクセス制限をする
     */
    const THRESHOLD_ACCESS_COUNT_WITHIN_60SECONDS = 30;

    /**
     * 60秒以内の連続アクセス違反の時のアクセス制限時間
     */
    const ACCESS_RESTRICTION_PERIOD_60 = '300 second';

    /**
     * 制限をかけないIPアドレス一覧
     */
    const ACCESS_RESTRICTION_ALLOWED_IPS = [
        '127.0.0.1' => null,
        '192.168.100.1' => null,
    ];
}
```

### function

```php
/**
 * IPアドレスのチェック（CodeIgniterのForm_validationより）
 *
 * @param string $ip
 * @param string $options 'ipv4' or 'ipv6' to validate a specific IP format
 * @return bool
 */
function valid_ip($ip, $options = null) {
    if (! is_null($options)) {
        $which = strtolower($options);
        if ('ipv4' === $which) {
            $options = 1048576;
        }
        else if ('ipv6' === $which) {
            $options = 2097152;
        }
    }
    return (bool)filter_var($ip, 275, $options);
}

/**
 * 単位時間内に同じIPアドレスからのアクセス数に応じてアクセス制限をする
 * ※適切な関数名を考案中
 *
 * @param string $ip_address
 * @param string $valid_ip_options
 */
function access_is_restricted_according_to_the_number_of_accesses_from_the_same_IP_address_within_a_unit_time($ip_address, $valid_ip_options) {

    // localhost接続
    if ('::1' === $ip_address) {
        $ip_address = '127.0.0.1';
    }

    // 例外
    if (array_key_exists($ip_address, config::ACCESS_RESTRICTION_ALLOWED_IPS)) {
        // 動作確認用
        echo "IPアドレス[{$ip_address}]は接続制限の例外に含まれています。";
        return;
    }

    if (! valid_ip($ip_address, $valid_ip_options)) {
        trigger_error(__FUNCTION__.'(): Argument #1 ($ip_address) must be in the form of an IP address.', E_USER_NOTICE);
        return;
    }

    access_is_restricted_according_to_the_number_of_accesses_from_the_same_XXXX_within_a_unit_time($ip_address);
}

/**
 * 単位時間内に同じ$arg1のアクセス数に応じてアクセス制限をする
 * ※適切な関数名を考案中
 *
 * @param string $arg1
 */
function access_is_restricted_according_to_the_number_of_accesses_from_the_same_XXXX_within_a_unit_time($arg1) {

    $arg1_type = gettype($arg1);
    if ('string' !== $arg1_type) {
        trigger_error(__FUNCTION__.'(): Argument #1 ($arg1) must be of type string.', E_USER_WARNING);
        return;
    }

    if ('' === strval($arg1)) {
        trigger_error(__FUNCTION__.'(): Argument #1 ($arg1) is empty.', E_USER_WARNING);
        return;
    }

    // ディレクトリ存在チェックと作成とパーミッション設定
    $access_history_dir = dirname(__FILE__);
    // $access_history_dir = 'アプリのtmpディレクトリなど適切なディレクトリ/access_history';
    if (! is_dir($access_history_dir)) {
        if (! @mkdir($access_history_dir, 0777, true)) {
            return;
        }
    }
    if (! is_writable($access_history_dir)) {
        @chmod($access_history_dir, 0777);
    }

    $access_history_path = $access_history_dir.'/'.str_replace(['.', ':'], '_', $arg1).'.ser';

    if (! is_file($access_history_path)) {
        $access_history_handle = fopen($access_history_path, 'w');
        $access_history_flock = flock($access_history_handle, LOCK_EX);
        $access_history = new \stdClass();
        $access_history->times = [];
        $access_history->access_restriction_period = 0;
    }
    else {
        $access_history_handle = fopen($access_history_path, 'r+');
        $access_history_flock = flock($access_history_handle, LOCK_EX);

        // ファイルに書き込まれている内容から、前回までのアクセス履歴を復元
        $access_history = unserialize(fgets($access_history_handle));
        if (! is_object($access_history) || ! property_exists($access_history, 'times') || ! is_array($access_history->times) || ! property_exists($access_history, 'access_restriction_period')) {
            $access_history = new \stdClass();
            $access_history->times = [];
            $access_history->access_restriction_period = 0;
        }
    }

    // ファイルロックをできなかった時はここから先は何もやれないのでアクセス履歴を更新する事なく終了
    if (! $access_history_flock) {
        trigger_error(__FUNCTION__.'(): File lock failed.', E_USER_WARNING);

        // 前回までのアクセス履歴でアクセス制限
        if (time() < $access_history->access_restriction_period) {
            exit(sprintf(
                'アクセス制限中<br>期限:%s'
                , date('Y-m-d H:i:s', $access_history->access_restriction_period)
            ));
        }

        return;
    }

    // アクセス履歴に追加
    $access_history->times[] = time();

    // 単位時間内の件数
    $time_10seconds_ago = strtotime('-10 second');
    $time_60seconds_ago = strtotime('-60 second');
    $count_within_10seconds = 0;
    $count_within_60seconds = 0;
    foreach ($access_history->times as $i => $access_time) {
        if ($time_60seconds_ago < $access_time) {
            ++$count_within_60seconds;
            if ($time_10seconds_ago < $access_time) {
                ++$count_within_10seconds;
            }
        }
        else {
            // 古いアクセス履歴の削除
            unset($access_history->times[$i]);
        }
    }

    // 古いアクセス履歴を削除したかもしれないので添字をリセット
    $access_history->times = array_values($access_history->times);

    // アクセス制限期限を更新
    if (config::THRESHOLD_ACCESS_COUNT_WITHIN_60SECONDS < $count_within_60seconds) {
        $access_history->access_restriction_period = strtotime('+'.config::ACCESS_RESTRICTION_PERIOD_60);
    }
    else if (config::THRESHOLD_ACCESS_COUNT_WITHIN_10SECONDS < $count_within_10seconds) {
        $access_history->access_restriction_period = strtotime('+'.config::ACCESS_RESTRICTION_PERIOD_10);
    }

    // アクセス履歴を保存
    ftruncate($access_history_handle, 0);
    rewind($access_history_handle);
    fputs($access_history_handle, serialize($access_history));
    fclose($access_history_handle);

    // アクセス制限
    if (time() < $access_history->access_restriction_period) {
        exit(sprintf(
            'アクセス制限中<br>期限:%s<br>10秒以内のアクセス回数:%d<br>60秒以内のアクセス回数:%d'
            , date('Y-m-d H:i:s', $access_history->access_restriction_period)
            , $count_within_10seconds
            , $count_within_60seconds
        ));
    }

    // 古いアクセス履歴ファイルを削除
    mt_srand();
    if (0 === mt_rand(0, 1000)) {
        foreach (glob($access_history_dir.'/*.ser') as $file) {
            if (filemtime($file) < strtotime('-1 hour')) {
                unlink($file);
            }
        }
    }

    // 動作確認用
    echo(sprintf(
        '10秒以内のアクセス回数:%d<br>60秒以内のアクセス回数:%d'
        , $count_within_10seconds
        , $count_within_60seconds
    ));
}
```