Príklad: autorizácia a obmedzenie požiadaviek
Štyri endpointy s rovnakým handlerom, každý chránený inak.
| Endpoint | Ochrana | Vyskúšajte |
|---|---|---|
/api/v1/bearer | BearerTokenAuthorization | -H 'Authorization: Bearer secret-token' |
/api/v1/api-key | HeaderApiKeyAuthentication | -H 'X-API-Key: secret-token' |
/api/v1/basic | BasicCredentialsAuthorization z príkladu | -u admin:change-me |
/api/v1/limited | NoAuthorization a FileRateLimit | štyri volania za minútu |
Služby
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 je povolený len z 10.0.0.0/8; test ukazuje, že podvrhnutá X-Forwarded-For nepomôže.
IP klienta
<?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';
}
}Vstavaný IpDetector dôveruje hlavičkám klienta. Tento číta len REMOTE_ADDR. Pozrite si Autorizáciu.
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';
}
}Vstavaná BasicAuthentication nefunguje s nette/http 3.2 a novším; táto trieda číta hlavičku Authorization a porovnáva cez hash_equals().
Obmedzenie požiadaviek
<?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);
}
}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"Časté otázky
Odkiaľ sa berú skutočné tokeny?
Z vášho vlastného TokenRepositoryInterface nad databázou. StaticTokenRepository s tokenmi v NEON je pre príklady a interné nástroje.
Prečo obmedzenie požiadaviek nie je autowirované?
Nette DI by ho odovzdalo každému volaniu addApi(). Pozrite si Obmedzenie požiadaviek.
Čo hovoria hlášky 401?
Napríklad Token doesn't exists or isn't active alebo Invalid IP. Pozrite si Autorizáciu.
Líši sa handler podľa endpointu?
Nie, WhoAmIHandler vráti endpoint, pre ktorý bol zavolaný, takže test vidí, ktorý bežal.
Môžem to vyskúšať vo webovej konzole?
Príklad OpenAPI a konzoly má v konzole endpoint chránený bearer tokenom.