Example: authorization and rate limit
Four endpoints with the same handler, each protected differently.
| Endpoint | Protection | Try |
|---|---|---|
/api/v1/bearer | BearerTokenAuthorization | -H 'Authorization: Bearer secret-token' |
/api/v1/api-key | HeaderApiKeyAuthentication | -H 'X-API-Key: secret-token' |
/api/v1/basic | BasicCredentialsAuthorization from the example | -u admin:change-me |
/api/v1/limited | NoAuthorization and FileRateLimit | four calls in a minute |
The services
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
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
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
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
#!/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.