RateLimiter

zhchenxin發表於2019-08-25

我們在平常開發中,經常遇到這樣的需求,例如如果使用者在一分鐘以內登陸失敗超過5次,則需要等一分鐘在重試;限制每個手機號每分鐘只能傳送一次簡訊驗證碼;api介面限速,每個ip每分鐘最多60次。對這樣的需求進行抽象,就是限制在一段時間內做某件事情的次數,避免伺服器遭受輪詢的破解。

在 Laravel 中,針對這種需求,抽象了一個類: RateLimiter。這個類就是用來解決這樣的問題的。

依賴

RateLimiter 的程式碼是在框架的 Cache 目錄下的,同時 RateLimiter 也依賴於 Cache 元件。

在 Laravel 中,Cache 有多種配置,例如 檔案快取、資料庫快取、apc、memcached、redis等等。如果應用部署在一臺伺服器上,那麼這些快取在使用上沒有什麼區別;如果部署在多臺伺服器上,那麼 RateLimiter 就會出現問題。

因此,在使用 RateLimiter 的時候,建議使用資料庫、redis、memcached 這樣的快取配置。

原理

我們以限流需求舉例,假設60秒內限制5次。當第一個請求到來的時候,我們需要初始化計數器為1,同時給計數器設定超時時間60秒。再來一個請求的時候,我們就把計數器加一,當第6個請求到來的時候,計時器已經是5了,超過了配置的5次請求,就會拒絕,並告訴客戶端超過了訪問次數。當60秒過去之後,計數器超時被刪除,這樣就可以重新接收新的請求。

方法

http://naotu.baidu.com/file/967c4de22f54ce...

登陸

Auth 元件的登陸介面使用了 RateLimiter,接下來我們看下在Auth元件中,是怎麼使用 RateLimiter 的。

首先,我們看下 login 的原始碼:

public function login(Request $request)
{
    $this->validateLogin($request);

    // If the class is using the ThrottlesLogins trait, we can automatically throttle
    // the login attempts for this application. We'll key this by the username and
    // the IP address of the client making these requests into this application.
    if ($this->hasTooManyLoginAttempts($request)) {
        $this->fireLockoutEvent($request);

        return $this->sendLockoutResponse($request);
    }

    if ($this->attemptLogin($request)) {
        return $this->sendLoginResponse($request);
    }

    // If the login attempt was unsuccessful we will increment the number of attempts
    // to login and redirect the user back to the login form. Of course, when this
    // user surpasses their maximum number of attempts they will get locked out.
    $this->incrementLoginAttempts($request);

    return $this->sendFailedLoginResponse($request);
}

在其中 hasTooManyLoginAttempts 方法裡面,呼叫了 RateLimiter 的 tooManyAttempts 方法,用於校驗是否超過了配置的次數。

protected function hasTooManyLoginAttempts(Request $request)
{
    return $this->limiter()->tooManyAttempts(
        $this->throttleKey($request), $this->maxAttempts()
    );
}

如果登陸超過了配置的次數,那麼就會從 RateLimiter 中下次能呼叫登陸介面所需要多少秒

protected function sendLockoutResponse(Request $request)
{
    $seconds = $this->limiter()->availableIn(
        $this->throttleKey($request)
    );

    throw ValidationException::withMessages([
        $this->username() => [Lang::get('auth.throttle', ['seconds' => $seconds])],
    ])->status(429);
}

如果登陸成功,在 sendLoginResponse 方法中,會呼叫 clear 方法,清空 RateLimter

protected function sendLoginResponse(Request $request)
{
    $request->session()->regenerate();

    $this->clearLoginAttempts($request);

    return $this->authenticated($request, $this->guard()->user())
            ?: redirect()->intended($this->redirectPath());
}

protected function clearLoginAttempts(Request $request)
{
    $this->limiter()->clear($this->throttleKey($request));
}

如果登陸失敗,會呼叫 hit 方法,讓計數器+1

protected function incrementLoginAttempts(Request $request)
{
    $this->limiter()->hit(
        $this->throttleKey($request), $this->decayMinutes()
    );
}

上面就是 RateLimiter 在登陸過程中的例子,總結下使用了那些方法

1. 首先,呼叫 tooManyAttempts 判斷計數器是否超過了最大次數
2. 如果超過了最大次數,就呼叫 availableIn 方法,獲取下次可以訪問還需要多少秒
3. 如果登陸成功,呼叫 clear 方法清空計數器
4. 如果登陸失敗,呼叫 hit 方法,讓計數器+1

限流中介軟體

ThrottleRequests 是 laravel 提供的一個限流中介軟體,我們來看下它的原始碼,看看 ThrottleRequests 是怎麼使用 RateLimiter

首先,先看下中介軟體的 Handler 方法:

public function handle($request, Closure $next, $maxAttempts = 60, $decayMinutes = 1)
{
    $key = $this->resolveRequestSignature($request);

    $maxAttempts = $this->resolveMaxAttempts($request, $maxAttempts);

    if ($this->limiter->tooManyAttempts($key, $maxAttempts)) {
        throw $this->buildException($key, $maxAttempts);
    }

    $this->limiter->hit($key, $decayMinutes);

    $response = $next($request);

    return $this->addHeaders(
        $response, $maxAttempts,
        $this->calculateRemainingAttempts($key, $maxAttempts)
    );
}

可以看到,handler 方法中首先呼叫了 tooManyAttempts 方法校驗是否已經達到最大次數,如果達到最大次數,那麼在 buildException 也會呼叫 availableIn 方法獲取下次請求的所需要多少秒:

protected function buildException($key, $maxAttempts)
{
    $retryAfter = $this->getTimeUntilNextRetry($key);

    $headers = $this->getHeaders(
        $maxAttempts,
        $this->calculateRemainingAttempts($key, $maxAttempts, $retryAfter),
        $retryAfter
    );

    return new ThrottleRequestsException(
        'Too Many Attempts.', null, $headers
    );
}

protected function getTimeUntilNextRetry($key)
{
    return $this->limiter->availableIn($key);
}

如果沒有超過最大次數呢,就呼叫 hit 方法將計數器+1。

在請求結束之後($next 閉包執行完成之後),也會呼叫 retriesLeft 獲取在一分鐘之內,剩餘多少次請求。

protected function calculateRemainingAttempts($key, $maxAttempts, $retryAfter = null)
{
    if (is_null($retryAfter)) {
        return $this->limiter->retriesLeft($key, $maxAttempts);
    }

    return 0;
}

接下來總結下在 ThrottleRequests 方法中,是如何使用 RateLimiter 的。

1. 首先呼叫 tooManyAttempts 判斷計數器是否超過了最大次數
2. 如果超過了最大次數,需要呼叫 availableIn 方法,獲取下次可以訪問還需要多少秒,同時將這個值放在 header 頭中,返回給客戶端
3. 如果沒有超過最大次數,呼叫 retriesLeft 方法,獲取在一個計數週期內,還剩下多少次請求,同時也將這個值放在 header 頭中,返回給客戶端。