Skip to content

Example: authorization and rate limit ​

Four endpoints with the same handler, each protected differently.

Open the diagram at full size
EndpointProtectionTry
/api/v1/bearerBearerTokenAuthorization-H 'Authorization: Bearer secret-token'
/api/v1/api-keyHeaderApiKeyAuthentication-H 'X-API-Key: secret-token'
/api/v1/basicBasicCredentialsAuthorization from the example-u admin:change-me
/api/v1/limitedNoAuthorization and FileRateLimitfour calls in a minute

The services ​

yaml
application:
	mapping:
		Api: Tomaj\NetteApi\Presenters\*Presenter

parameters:
	# token => allowed IPs: '*' for any, one IP, a list, or an IPv4 CIDR range
	apiTokens:
		secret-token: '*'
		office-token: '10.0.0.0/8'
	apiUsers:
		admin: change-me

services:
	router: App\RouterFactory::create
	apiPresenter:
		factory: Tomaj\NetteApi\Presenters\ApiPresenter
	- Tomaj\NetteApi\Output\Configurator\EnvConfigurator
	- Tomaj\NetteApi\Error\DefaultErrorHandler

	# the only IpDetectorInterface service, so it is autowired everywhere
	- App\RemoteAddrIpDetector
	tokens: Tomaj\NetteApi\Misc\StaticTokenRepository(%apiTokens%)

	bearer: Tomaj\NetteApi\Authorization\BearerTokenAuthorization(@tokens)
	apiKey: Tomaj\NetteApi\Authorization\HeaderApiKeyAuthentication('X-API-Key', @tokens)
	basic: App\BasicCredentialsAuthorization(%apiUsers%)
	rateLimit:
		factory: App\FileRateLimit(directory: %tempDir%)
		# otherwise Nette DI passes it as the 4th argument of every addApi() call below
		autowired: false

	- App\Api\WhoAmIHandler

	apiDecider:
		factory: Tomaj\NetteApi\ApiDecider
		setup:
			# Authorization: Bearer secret-token
			- addApi(Tomaj\NetteApi\EndpointIdentifier('GET', 1, 'bearer'), App\Api\WhoAmIHandler, @bearer)
			# X-API-Key: secret-token
			- addApi(Tomaj\NetteApi\EndpointIdentifier('GET', 1, 'api-key'), App\Api\WhoAmIHandler, @apiKey)
			# curl -u admin:change-me
			- addApi(Tomaj\NetteApi\EndpointIdentifier('GET', 1, 'basic'), App\Api\WhoAmIHandler, @basic)
			# no authorization, 3 requests per minute per IP
			- addApi(Tomaj\NetteApi\EndpointIdentifier('GET', 1, 'limited'), App\Api\WhoAmIHandler, Tomaj\NetteApi\Authorization\NoAuthorization(), @rateLimit)

office-token is allowed only from 10.0.0.0/8; the test shows that a forged X-Forwarded-For does not help.

The client IP ​

php
<?php

declare(strict_types=1);

namespace App;

use Tomaj\NetteApi\Misc\IpDetectorInterface;

/**
 * Uses only the address of the TCP connection.
 *
 * The bundled Tomaj\NetteApi\Misc\IpDetector prefers the Client-IP and X-Forwarded-For
 * headers, which any client can send. Use it only behind a proxy that overwrites them.
 */
final class RemoteAddrIpDetector implements IpDetectorInterface
{
    public function getRequestIp(): string
    {
        return $_SERVER['REMOTE_ADDR'] ?? 'cli';
    }
}

The bundled IpDetector trusts client headers. This one reads only REMOTE_ADDR. See Authorization.

HTTP Basic ​

php
<?php

declare(strict_types=1);

namespace App;

use Nette\Http\IRequest;
use Tomaj\NetteApi\Authorization\ApiAuthorizationInterface;

/**
 * HTTP Basic authorization that reads the Authorization header.
 *
 * The bundled BasicAuthentication reads the user and password from the request URL,
 * which nette/http fills only up to version 3.1. Since nette/http 3.2 the URL has no
 * credentials, so use a class like this one, or install nette/http 3.1.
 */
final class BasicCredentialsAuthorization implements ApiAuthorizationInterface
{
    /**
     * @param array<string, string> $users username => password
     */
    public function __construct(private array $users, private IRequest $request)
    {
    }

    public function authorized(): bool
    {
        $credentials = $this->request->getBasicCredentials();
        if ($credentials === null) {
            return false;
        }
        [$user, $password] = $credentials;
        // hash_equals compares in constant time, so the response time leaks nothing
        return isset($this->users[$user]) && hash_equals($this->users[$user], $password);
    }

    public function getErrorMessage(): ?string
    {
        return 'Incorrect username or password';
    }
}

The bundled BasicAuthentication does not work with nette/http 3.2 and newer; this class reads the Authorization header and compares with hash_equals().

Rate limit ​

php
<?php

declare(strict_types=1);

namespace App;

use Tomaj\NetteApi\Misc\IpDetectorInterface;
use Tomaj\NetteApi\RateLimit\RateLimitInterface;
use Tomaj\NetteApi\RateLimit\RateLimitResponse;

/**
 * A fixed window per client IP, counted in a file. Use Redis or a database in production.
 */
final class FileRateLimit implements RateLimitInterface
{
    public function __construct(
        private IpDetectorInterface $ipDetector,
        private string $directory,
        private int $limit = 3,
        private int $window = 60,
    ) {
    }

    public function check(): ?RateLimitResponse
    {
        $windowStart = intdiv(time(), $this->window) * $this->window;
        $file = $this->directory . '/rate-' . md5($this->ipDetector->getRequestIp()) . '-' . $windowStart;

        $hits = (int) @file_get_contents($file) + 1;
        file_put_contents($file, (string) $hits, LOCK_EX);

        $retryAfter = $windowStart + $this->window - time();

        // ApiPresenter answers 429 with Retry-After when remaining is 0,
        // so the request being checked is not counted in remaining yet
        $remaining = max(0, $this->limit - $hits + 1);
        return new RateLimitResponse($this->limit, $remaining, $retryAfter);
    }
}

The test ​

bash
#!/usr/bin/env bash
# End-to-end check: each authorization passes with the right credentials and answers 401 without them.
set -euo pipefail
cd "$(dirname "$0")"
[[ -f vendor/autoload.php ]] || { echo "fail: vendor/ is missing, run composer install first" >&2; exit 1; }
source ../ci/lib.sh
rm -f temp/rate-*
serve 8083

request -H 'Authorization: Bearer secret-token' "$BASE/api/v1/bearer"
expect_status 200 "bearer token"
expect_body '"endpoint":"v1/bearer/"' "bearer token"
ok "a valid bearer token passes"

request "$BASE/api/v1/bearer"
expect_status 401 "no bearer token"
expect_body '"message":"Authorization header HTTP_Authorization is not set"' "no bearer token"
ok "no Authorization header answers 401"

request -H 'Authorization: Bearer wrong' "$BASE/api/v1/bearer"
expect_status 401 "unknown bearer token"
expect_body "\"message\":\"Token doesn't exists or isn't active\"" "unknown bearer token"
ok "an unknown token answers 401"

request -H 'Authorization: Bearer office-token' -H 'X-Forwarded-For: 10.1.2.3' "$BASE/api/v1/bearer"
expect_status 401 "token from a wrong IP"
expect_body '"message":"Invalid IP"' "token from a wrong IP"
ok "an IP restricted token from 127.0.0.1 answers 401, a forged X-Forwarded-For does not help"

request -H 'X-API-Key: secret-token' "$BASE/api/v1/api-key"
expect_status 200 "api key"
ok "a valid X-API-Key passes"

request "$BASE/api/v1/api-key"
expect_status 401 "no api key"
expect_body '"message":"API key is not set"' "no api key"
ok "no X-API-Key answers 401"

request -u admin:change-me "$BASE/api/v1/basic"
expect_status 200 "basic auth"
ok "HTTP Basic with the right password passes"

request -u admin:wrong "$BASE/api/v1/basic"
expect_status 401 "basic auth wrong password"
expect_body '"message":"Incorrect username or password"' "basic auth wrong password"
ok "HTTP Basic with a wrong password answers 401"

for i in 1 2 3; do
    request "$BASE/api/v1/limited"
    expect_status 200 "rate limit request $i"
done
expect_header 'X-RateLimit-Limit: 3' "rate limit headers"
expect_header 'X-RateLimit-Remaining: 1' "rate limit headers"
request "$BASE/api/v1/limited"
expect_status 429 "rate limit exceeded"
expect_header 'Retry-After:' "rate limit exceeded"
expect_body '"message":"Too many requests. Retry after' "rate limit exceeded"
ok "the fourth request in a minute answers 429 with Retry-After"

FAQ ​

Where do real tokens come from? ​

From your own TokenRepositoryInterface backed by a database. StaticTokenRepository with tokens in NEON is for examples and internal tools.

Why is the rate limit not autowired? ​

Nette DI would pass it to every addApi() call. See Rate limiting.

What do the 401 messages say? ​

For example Token doesn't exists or isn't active or Invalid IP. See Authorization.

Is the handler different per endpoint? ​

No, WhoAmIHandler returns the endpoint it was called for, so the test can see which one ran.

Can I try these in the web console? ​

The OpenAPI and console example has a bearer protected endpoint in the console.

Released under the MIT License.