Add JWT and (better) basic auth

This commit is contained in:
Maurits van der Schee 2018-09-29 22:38:58 +02:00
commit 13e3e3efe4
24 changed files with 262 additions and 45 deletions

View file

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

View file

@ -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;

View file

@ -87,7 +87,7 @@ class Config
}
}
}
$newValues['middlewares'] = $properties;
$newValues['middlewares'] = array_reverse($properties, true);
return $newValues;
}

View file

@ -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);
}
}

View 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);
}
}

View file

@ -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
View file

@ -0,0 +1 @@
username1:$2y$10$Qov96xrFqrbaTu3e87SUD.ZH5MGrJ5q/xSDMoKxgZhK2H7TMNuVym

View file

@ -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');

View 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"}

View 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"}