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 a FixedWindowLimiter with NetteCacheStoragefour 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; it cannot be forged with headers
	- Tomaj\NetteApi\Misc\IpDetector
	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%)
	# 3 requests per minute per IP, counted in the Nette cache (%tempDir%/cache)
	rateLimit:
		factory: Tomaj\NetteApi\RateLimit\FixedWindowLimiter(perIp, 3, 60, Tomaj\NetteApi\RateLimit\Key\IpKey(), Tomaj\NetteApi\RateLimit\Storage\NetteCacheStorage())

	- 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 ​

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.
 * Nette API 4.0 reads the header in BasicAuthentication, so there this class is not needed.
 */
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';
    }
}

In 3.x the bundled BasicAuthentication does not work with nette/http 3.2 and newer (4.0 fixes it); this class reads the Authorization header and compares with hash_equals().

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 -rf temp/cache
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"

avoid_minute_edge
for i in 1 2 3; do
    request "$BASE/api/v1/limited"
    expect_status 200 "rate limit request $i"
done
expect_header 'RateLimit-Policy: "perIp";q=3;w=60' "rate limit headers"
expect_header 'RateLimit: "perIp";r=0;' "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.

How does the rate limit get to an endpoint? ​

addApi() gets it as the 4th argument, a RateLimiterInterface is not autowired. 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.