Add JWT and (better) basic auth
This commit is contained in:
parent
dc9324ec46
commit
13e3e3efe4
24 changed files with 262 additions and 45 deletions
60
README.md
60
README.md
|
|
@ -91,8 +91,7 @@ These limitation were also present in v1:
|
|||
- Composite primary or foreign keys are not supported
|
||||
- Complex writes (transactions) are not supported
|
||||
- Complex queries calling functions (like "concat" or "sum") are not supported
|
||||
- MySQL storage engine must be either InnoDB or XtraDB
|
||||
- Only MySQL, PostgreSQL and SQLServer support spatial/GIS functionality
|
||||
- Database must support and define foreign key constraints
|
||||
|
||||
## Features
|
||||
|
||||
|
|
@ -120,7 +119,7 @@ These features match features in v1 (see branch "v1"):
|
|||
- [x] Spatial/GIS fields and filters supported with WKT
|
||||
- [ ] Unstructured data support through JSON/JSONB
|
||||
- [ ] Generate API documentation using OpenAPI tools
|
||||
- [ ] Authentication via JWT token or username/password
|
||||
- [x] Authentication via JWT token or username/password
|
||||
- [ ] ~~SQLite support~~
|
||||
|
||||
NB: No checkmark means: not yet implemented. Striken means: will not be implemented.
|
||||
|
|
@ -141,28 +140,31 @@ These features are new and were not included in v1.
|
|||
|
||||
You can enable the following middleware using the "middlewares" config parameter:
|
||||
|
||||
- "cors": Support for CORS requests (enabled by default)
|
||||
- "authorization": Restrict access to certain tables or columns
|
||||
- "basicAuth": Support for "Basic Authentication"
|
||||
- "firewall": Limit access to specific IP addresses
|
||||
- "cors": Support for CORS requests (enabled by default)
|
||||
- "jwtAuth": Support for "Basic Authentication"
|
||||
- "basicAuth": Support for "Basic Authentication"
|
||||
- "authorization": Restrict access to certain tables or columns
|
||||
- "validation": Return input validation errors for custom rules
|
||||
- "sanitation": Apply input sanitation on create and update
|
||||
|
||||
The "middlewares" config parameter is a comma separated list of enabled middlewares.
|
||||
You can tune the middleware behavior using middleware specific configuration parameters:
|
||||
|
||||
- "firewall.reverseProxy": Set to "true" when a reverse proxy is used ("")
|
||||
- "firewall.allowedIpAddresses": List of IP addresses that are allowed to connect ("")
|
||||
- "cors.allowedOrigins": The origins allowed in the CORS headers ("*")
|
||||
- "cors.allowHeaders": The headers allowed in the CORS request ("Content-Type, X-XSRF-TOKEN")
|
||||
- "cors.allowMethods": The methods allowed in the CORS request ("OPTIONS, GET, PUT, POST, DELETE, PATCH")
|
||||
- "cors.allowCredentials": To allow credentials in the CORS request ("true")
|
||||
- "cors.maxAge": The time that the CORS grant is valid in seconds ("1728000")
|
||||
- "jwtAuth.leeway": The acceptable number of seconds of clock skew ("5")
|
||||
- "jwtAuth.ttl": The number of seconds the token is valid ("30")
|
||||
- "jwtAuth.secret": The shared secret used to sign the JWT token with ("")
|
||||
- "basicAuth.passwordFile": The file to read for username/password combinations (".htpasswd")
|
||||
- "authorization.tableHandler": Handler to implement table authorization rules ("")
|
||||
- "authorization.columnHandler": Handler to implement column authorization rules ("")
|
||||
- "authorization.recordHandler": Handler to implement record authorization filter rules ("")
|
||||
- "basicAuth.passwordFile": The file to read for username/password combinations (".htpasswd")
|
||||
- "basicAuth.realm": Message shown when asking for credentials ("Username and password required")
|
||||
- "firewall.reverseProxy": Set to "true" when a reverse proxy is used ("")
|
||||
- "firewall.allowedIpAddresses": List of IP addresses that are allowed to connect ("")
|
||||
- "validation.handler": Handler to implement validation rules for input values ("")
|
||||
- "sanitation.handler": Handler to implement sanitation rules for input values ("")
|
||||
|
||||
|
|
@ -553,6 +555,44 @@ For spatial support there is an extra set of filters that can be applied on geom
|
|||
|
||||
These filters are based on OGC standards and so is the WKT specification in which the geometry columns are represented.
|
||||
|
||||
### Authentication
|
||||
|
||||
Authentication is done by means of sending a "Authorization" header. It identifies the user and stores this in the `$_SESSION` super global.
|
||||
This variable can be used in the authorization handlers to decide wether or not sombeody should have read or write access to certain tables, columns or records.
|
||||
Currently there are two types of authentication supported: "Basic" and "JWT".
|
||||
|
||||
#### Basic authentication
|
||||
|
||||
The Basic type supports a file that holds the users and their (hashed) passwords separated by a colon (':').
|
||||
When the passwords are entered in plain text they fill be automatically hashed.
|
||||
The authenticated username will be stored in the `$_SESSION['username']` variable.
|
||||
You need to send an "Authorization" header containing a base64 url encoded and colon separated username and password after the word "Basic".
|
||||
|
||||
Authorization: Basic dXNlcm5hbWUxOnBhc3N3b3JkMQ
|
||||
|
||||
This example sends the string "username1:password1".
|
||||
|
||||
#### JWT authentication
|
||||
|
||||
The JWT type requires another (SSO/Identity) server to sign a token that contains claims.
|
||||
Both servers share a secret so that they can either sign or verify that the signature is valid.
|
||||
Claims are stored in the `$_SESSION['claims']` variable.
|
||||
You need to send an "Authorization" header containing a base64 url encoded and dot separated token header, body and signature after the word "Bearer" (read more abou).
|
||||
|
||||
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6IjE1MzgyMDc2MDUiLCJleHAiOjE1MzgyMDc2MzV9.Z5px_GT15TRKhJCTHhDt5Z6K6LRDSFnLj8U5ok9l7gw
|
||||
|
||||
This example sends the signed string:
|
||||
|
||||
{
|
||||
"sub": "1234567890",
|
||||
"name": "John Doe",
|
||||
"admin": true,
|
||||
"iat": "1538207605",
|
||||
"exp": 1538207635
|
||||
}
|
||||
|
||||
NB: The JWT implementation only supports the hash based algorithms HS256, HS384 and HS512.
|
||||
|
||||
### Authorizing tables, columns and records
|
||||
|
||||
By default all tables are reflected. If you want to restrict access to some tables you may add the 'authorization' middleware
|
||||
|
|
|
|||
|
|
@ -11,8 +11,10 @@ use Tqdev\PhpCrudApi\Controller\RecordController;
|
|||
use Tqdev\PhpCrudApi\Controller\Responder;
|
||||
use Tqdev\PhpCrudApi\Database\GenericDB;
|
||||
use Tqdev\PhpCrudApi\Middleware\AuthorizationMiddleware;
|
||||
use Tqdev\PhpCrudApi\Middleware\BasicAuthMiddleware;
|
||||
use Tqdev\PhpCrudApi\Middleware\CorsMiddleware;
|
||||
use Tqdev\PhpCrudApi\Middleware\FirewallMiddleware;
|
||||
use Tqdev\PhpCrudApi\Middleware\JwtAuthMiddleware;
|
||||
use Tqdev\PhpCrudApi\Middleware\Router\SimpleRouter;
|
||||
use Tqdev\PhpCrudApi\Middleware\SanitationMiddleware;
|
||||
use Tqdev\PhpCrudApi\Middleware\ValidationMiddleware;
|
||||
|
|
@ -51,6 +53,9 @@ class Api
|
|||
case 'basicAuth':
|
||||
new BasicAuthMiddleware($router, $responder, $properties);
|
||||
break;
|
||||
case 'jwtAuth':
|
||||
new JwtAuthMiddleware($router, $responder, $properties);
|
||||
break;
|
||||
case 'validation':
|
||||
new ValidationMiddleware($router, $responder, $properties, $reflection);
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ class Config
|
|||
}
|
||||
}
|
||||
}
|
||||
$newValues['middlewares'] = $properties;
|
||||
$newValues['middlewares'] = array_reverse($properties, true);
|
||||
return $newValues;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ use Tqdev\PhpCrudApi\Response;
|
|||
|
||||
class BasicAuthMiddleware extends Middleware
|
||||
{
|
||||
private function isAllowed(String $username, String $password, array &$passwords): bool
|
||||
private function hasCorrectPassword(String $username, String $password, array &$passwords): bool
|
||||
{
|
||||
$hash = isset($passwords[$username]) ? $passwords[$username] : false;
|
||||
if ($hash && password_verify($password, $hash)) {
|
||||
|
|
@ -21,21 +21,12 @@ class BasicAuthMiddleware extends Middleware
|
|||
return false;
|
||||
}
|
||||
|
||||
private function authenticate(String $username, String $password, String $passwordFile): bool
|
||||
private function getValidUsername(String $username, String $password, String $passwordFile): String
|
||||
{
|
||||
if (session_status() == PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
if (isset($_SESSION['user']) && $_SESSION['user'] == $username) {
|
||||
return true;
|
||||
}
|
||||
$passwords = $this->readPasswords($passwordFile);
|
||||
$allowed = $this->isAllowed($username, $password, $passwords);
|
||||
if ($allowed) {
|
||||
$_SESSION['user'] = $username;
|
||||
}
|
||||
$valid = $this->hasCorrectPassword($username, $password, $passwords);
|
||||
$this->writePasswords($passwordFile, $passwords);
|
||||
return $allowed;
|
||||
return $valid ? $username : '';
|
||||
}
|
||||
|
||||
private function readPasswords(String $passwordFile): array
|
||||
|
|
@ -67,20 +58,36 @@ class BasicAuthMiddleware extends Middleware
|
|||
return $success;
|
||||
}
|
||||
|
||||
private function getAuthorizationCredentials(Request $request): String
|
||||
{
|
||||
$parts = explode(' ', trim($request->getHeader('Authorization')), 2);
|
||||
if (count($parts) != 2) {
|
||||
return '';
|
||||
}
|
||||
if ($parts[0] != 'Basic') {
|
||||
return '';
|
||||
}
|
||||
return base64_decode(strtr($parts[1], '-_', '+/'));
|
||||
}
|
||||
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$username = isset($_SERVER['PHP_AUTH_USER']) ? $_SERVER['PHP_AUTH_USER'] : '';
|
||||
$password = isset($_SERVER['PHP_AUTH_PW']) ? $_SERVER['PHP_AUTH_PW'] : '';
|
||||
if (session_status() == PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
$credentials = $this->getAuthorizationCredentials($request);
|
||||
if ($credentials) {
|
||||
list($username, $password) = array('', '');
|
||||
if (strpos($credentials, ':') !== false) {
|
||||
list($username, $password) = explode(':', $credentials, 2);
|
||||
}
|
||||
$passwordFile = $this->getProperty('passwordFile', '.htpasswd');
|
||||
if (!$username) {
|
||||
$response = $this->responder->error(ErrorCode::AUTHORIZATION_REQUIRED, $username);
|
||||
$realm = $this->getProperty('realm', 'Username and password required');
|
||||
$response->addHeader('WWW-Authenticate', "Basic realm=\"$realm\"");
|
||||
} elseif (!$this->authenticate($username, $password, $passwordFile)) {
|
||||
$response = $this->responder->error(ErrorCode::ACCESS_DENIED, $username);
|
||||
} else {
|
||||
$response = $this->next->handle($request);
|
||||
}
|
||||
return $response;
|
||||
$validUser = $this->getValidUsername($username, $password, $passwordFile);
|
||||
$_SESSION['username'] = $validUser;
|
||||
if (!$validUser) {
|
||||
return $this->responder->error(ErrorCode::ACCESS_DENIED, $username);
|
||||
}
|
||||
}
|
||||
return $this->next->handle($request);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
95
src/Tqdev/PhpCrudApi/Middleware/JwtAuthMiddleware.php
Normal file
95
src/Tqdev/PhpCrudApi/Middleware/JwtAuthMiddleware.php
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
<?php
|
||||
namespace Tqdev\PhpCrudApi\Middleware;
|
||||
|
||||
use Tqdev\PhpCrudApi\Controller\Responder;
|
||||
use Tqdev\PhpCrudApi\Middleware\Base\Middleware;
|
||||
use Tqdev\PhpCrudApi\Record\ErrorCode;
|
||||
use Tqdev\PhpCrudApi\Request;
|
||||
use Tqdev\PhpCrudApi\Response;
|
||||
|
||||
class JwtAuthMiddleware extends Middleware
|
||||
{
|
||||
private function getVerifiedClaims(String $token, int $time, int $leeway, int $ttl, String $secret): array
|
||||
{
|
||||
$algorithms = array('HS256' => 'sha256', 'HS384' => 'sha384', 'HS512' => 'sha512');
|
||||
$token = explode('.', $token);
|
||||
if (count($token) < 3) {
|
||||
return array();
|
||||
}
|
||||
$header = json_decode(base64_decode(strtr($token[0], '-_', '+/')), true);
|
||||
if (!$secret) {
|
||||
return array();
|
||||
}
|
||||
if ($header['typ'] != 'JWT') {
|
||||
return array();
|
||||
}
|
||||
$algorithm = $header['alg'];
|
||||
if (!isset($algorithms[$algorithm])) {
|
||||
return array();
|
||||
}
|
||||
$hmac = $algorithms[$algorithm];
|
||||
$signature = bin2hex(base64_decode(strtr($token[2], '-_', '+/')));
|
||||
if ($signature != hash_hmac($hmac, "$token[0].$token[1]", $secret)) {
|
||||
return array();
|
||||
}
|
||||
$claims = json_decode(base64_decode(strtr($token[1], '-_', '+/')), true);
|
||||
if (!$claims) {
|
||||
return array();
|
||||
}
|
||||
if (isset($claims['nbf']) && $time + $leeway < $claims['nbf']) {
|
||||
return array();
|
||||
}
|
||||
if (isset($claims['iat']) && $time + $leeway < $claims['iat']) {
|
||||
return array();
|
||||
}
|
||||
if (isset($claims['exp']) && $time - $leeway > $claims['exp']) {
|
||||
return array();
|
||||
}
|
||||
if (isset($claims['iat']) && !isset($claims['exp'])) {
|
||||
if ($time - $leeway > $claims['iat'] + $ttl) {
|
||||
return array();
|
||||
}
|
||||
}
|
||||
return $claims;
|
||||
}
|
||||
|
||||
private function getClaims(String $token): array
|
||||
{
|
||||
$time = (int) $this->getProperty('time', time());
|
||||
$leeway = (int) $this->getProperty('leeway', '5');
|
||||
$ttl = (int) $this->getProperty('ttl', '30');
|
||||
$secret = $this->getProperty('secret', '');
|
||||
if (!$secret) {
|
||||
return array();
|
||||
}
|
||||
return $this->getVerifiedClaims($token, $time, $leeway, $ttl, $secret);
|
||||
}
|
||||
|
||||
private function getAuthorizationToken(Request $request): String
|
||||
{
|
||||
$parts = explode(' ', trim($request->getHeader('Authorization')), 2);
|
||||
if (count($parts) != 2) {
|
||||
return '';
|
||||
}
|
||||
if ($parts[0] != 'Bearer') {
|
||||
return '';
|
||||
}
|
||||
return $parts[1];
|
||||
}
|
||||
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
if (session_status() == PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
$token = $this->getAuthorizationToken($request);
|
||||
if ($token) {
|
||||
$claims = $this->getClaims($token);
|
||||
$_SESSION['claims'] = $claims;
|
||||
if (empty($claims)) {
|
||||
return $this->responder->error(ErrorCode::ACCESS_DENIED, 'JWT');
|
||||
}
|
||||
}
|
||||
return $this->next->handle($request);
|
||||
}
|
||||
}
|
||||
12
test.php
12
test.php
|
|
@ -8,7 +8,7 @@ spl_autoload_register(function ($class) {
|
|||
include str_replace('\\', '/', "src\\$class.php");
|
||||
});
|
||||
|
||||
function runDir(Api $api, String $dir, array $matches, String $category): array
|
||||
function runDir(Config $config, String $dir, array $matches, String $category): array
|
||||
{
|
||||
$success = 0;
|
||||
$total = 0;
|
||||
|
|
@ -27,10 +27,10 @@ function runDir(Api $api, String $dir, array $matches, String $category): array
|
|||
if (substr($entry, -4) != '.log') {
|
||||
continue;
|
||||
}
|
||||
$success += runTest($api, $file, $category);
|
||||
$success += runTest($config, $file, $category);
|
||||
$total += 1;
|
||||
} elseif (is_dir($file)) {
|
||||
$statistics = runDir($api, $file, array_slice($matches, 1), "$category/$entry");
|
||||
$statistics = runDir($config, $file, array_slice($matches, 1), "$category/$entry");
|
||||
$total += $statistics['total'];
|
||||
$success += $statistics['success'];
|
||||
}
|
||||
|
|
@ -39,7 +39,7 @@ function runDir(Api $api, String $dir, array $matches, String $category): array
|
|||
return compact('total', 'success', 'failed');
|
||||
}
|
||||
|
||||
function runTest(Api $api, String $file, String $category): int
|
||||
function runTest(Config $config, String $file, String $category): int
|
||||
{
|
||||
$title = ucwords(str_replace('_', ' ', $category)) . '/';
|
||||
$title .= ucwords(str_replace('_', ' ', substr(basename($file), 0, -4)));
|
||||
|
|
@ -61,6 +61,7 @@ function runTest(Api $api, String $file, String $category): int
|
|||
}
|
||||
$in = $parts[$i];
|
||||
$exp = $parts[$i + 1];
|
||||
$api = new Api($config);
|
||||
$out = $api->handle(Request::fromString($in));
|
||||
if ($recording) {
|
||||
$parts[$i + 1] = $out;
|
||||
|
|
@ -123,8 +124,7 @@ function run(array $drivers, String $dir, array $matches)
|
|||
$config = new Config($settings);
|
||||
loadFixture($dir, $config);
|
||||
$start = microtime(true);
|
||||
$api = new Api($config);
|
||||
$stats = runDir($api, "$dir/functional", $matches, '');
|
||||
$stats = runDir($config, "$dir/functional", $matches, '');
|
||||
$end = microtime(true);
|
||||
$time = ($end - $start) * 1000;
|
||||
$total = $stats['total'];
|
||||
|
|
|
|||
1
tests/config/.htpasswd
Normal file
1
tests/config/.htpasswd
Normal file
|
|
@ -0,0 +1 @@
|
|||
username1:$2y$10$Qov96xrFqrbaTu3e87SUD.ZH5MGrJ5q/xSDMoKxgZhK2H7TMNuVym
|
||||
|
|
@ -3,9 +3,12 @@ $settings = [
|
|||
'database' => 'php-crud-api',
|
||||
'username' => 'php-crud-api',
|
||||
'password' => 'php-crud-api',
|
||||
'middlewares' => 'cors,authorization,validation,sanitation',
|
||||
'middlewares' => 'cors,jwtAuth,basicAuth,authorization,validation,sanitation',
|
||||
'jwtAuth.time' => '1538207605',
|
||||
'jwtAuth.secret' => 'axpIrCGNGqxzx2R9dtXLIPUSqPo778uhb8CA0F4Hx',
|
||||
'basicAuth.passwordFile' => __DIR__ . DIRECTORY_SEPARATOR . '.htpasswd',
|
||||
'authorization.tableHandler' => function ($method, $path, $databaseName, $tableName) {
|
||||
return !($tableName == 'invisibles');
|
||||
return !($tableName == 'invisibles' && !isset($_SESSION['claims']['name']) && empty($_SESSION['username']));
|
||||
},
|
||||
'authorization.columnHandler' => function ($method, $path, $databaseName, $tableName, $columnName) {
|
||||
return !($columnName == 'invisible');
|
||||
|
|
|
|||
33
tests/functional/002_auth/001_jwt_auth.log
Normal file
33
tests/functional/002_auth/001_jwt_auth.log
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
GET /records/invisibles/e42c77c6-06a4-4502-816c-d112c7142e6d
|
||||
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6IjE1MzgyMDc2MDUiLCJleHAiOjE1MzgyMDc2MzV9.Z5px_GT15TRKhJCTHhDt5Z6K6LRDSFnLj8U5ok9l7gw
|
||||
===
|
||||
200
|
||||
Content-Type: application/json
|
||||
Content-Length: 45
|
||||
|
||||
{"id":"e42c77c6-06a4-4502-816c-d112c7142e6d"}
|
||||
===
|
||||
GET /records/invisibles/e42c77c6-06a4-4502-816c-d112c7142e6d
|
||||
===
|
||||
200
|
||||
Content-Type: application/json
|
||||
Content-Length: 45
|
||||
|
||||
{"id":"e42c77c6-06a4-4502-816c-d112c7142e6d"}
|
||||
===
|
||||
GET /records/invisibles/e42c77c6-06a4-4502-816c-d112c7142e6d
|
||||
Authorization: Bearer invalid
|
||||
===
|
||||
403
|
||||
Content-Type: application/json
|
||||
Content-Length: 49
|
||||
|
||||
{"code":1012,"message":"Access denied for 'JWT'"}
|
||||
===
|
||||
GET /records/invisibles/e42c77c6-06a4-4502-816c-d112c7142e6d
|
||||
===
|
||||
404
|
||||
Content-Type: application/json
|
||||
Content-Length: 54
|
||||
|
||||
{"code":1001,"message":"Table 'invisibles' not found"}
|
||||
33
tests/functional/002_auth/002_basic_auth.log
Normal file
33
tests/functional/002_auth/002_basic_auth.log
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
GET /records/invisibles/e42c77c6-06a4-4502-816c-d112c7142e6d
|
||||
Authorization: Basic dXNlcm5hbWUxOnBhc3N3b3JkMQ
|
||||
===
|
||||
200
|
||||
Content-Type: application/json
|
||||
Content-Length: 45
|
||||
|
||||
{"id":"e42c77c6-06a4-4502-816c-d112c7142e6d"}
|
||||
===
|
||||
GET /records/invisibles/e42c77c6-06a4-4502-816c-d112c7142e6d
|
||||
===
|
||||
200
|
||||
Content-Type: application/json
|
||||
Content-Length: 45
|
||||
|
||||
{"id":"e42c77c6-06a4-4502-816c-d112c7142e6d"}
|
||||
===
|
||||
GET /records/invisibles/e42c77c6-06a4-4502-816c-d112c7142e6d
|
||||
Authorization: Basic aW52YWxpZHVzZXI6aW52YWxpZHBhc3M
|
||||
===
|
||||
403
|
||||
Content-Type: application/json
|
||||
Content-Length: 57
|
||||
|
||||
{"code":1012,"message":"Access denied for 'invaliduser'"}
|
||||
===
|
||||
GET /records/invisibles/e42c77c6-06a4-4502-816c-d112c7142e6d
|
||||
===
|
||||
404
|
||||
Content-Type: application/json
|
||||
Content-Length: 54
|
||||
|
||||
{"code":1001,"message":"Table 'invisibles' not found"}
|
||||
Loading…
Add table
Add a link
Reference in a new issue