Skip to content

Commit 13e3e3e

Browse files
committed
Add JWT and (better) basic auth
1 parent dc9324e commit 13e3e3e

24 files changed

+261
-44
lines changed

README.md

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,8 @@ These limitation were also present in v1:
9191
- Composite primary or foreign keys are not supported
9292
- Complex writes (transactions) are not supported
9393
- Complex queries calling functions (like "concat" or "sum") are not supported
94-
- MySQL storage engine must be either InnoDB or XtraDB
95-
- Only MySQL, PostgreSQL and SQLServer support spatial/GIS functionality
96-
94+
- Database must support and define foreign key constraints
95+
9796
## Features
9897

9998
These features match features in v1 (see branch "v1"):
@@ -120,7 +119,7 @@ These features match features in v1 (see branch "v1"):
120119
- [x] Spatial/GIS fields and filters supported with WKT
121120
- [ ] Unstructured data support through JSON/JSONB
122121
- [ ] Generate API documentation using OpenAPI tools
123-
- [ ] Authentication via JWT token or username/password
122+
- [x] Authentication via JWT token or username/password
124123
- [ ] ~~SQLite support~~
125124

126125
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.
141140

142141
You can enable the following middleware using the "middlewares" config parameter:
143142

143+
- "firewall": Limit access to specific IP addresses
144144
- "cors": Support for CORS requests (enabled by default)
145-
- "authorization": Restrict access to certain tables or columns
145+
- "jwtAuth": Support for "Basic Authentication"
146146
- "basicAuth": Support for "Basic Authentication"
147-
- "firewall": Limit access to specific IP addresses
147+
- "authorization": Restrict access to certain tables or columns
148148
- "validation": Return input validation errors for custom rules
149149
- "sanitation": Apply input sanitation on create and update
150150

151151
The "middlewares" config parameter is a comma separated list of enabled middlewares.
152152
You can tune the middleware behavior using middleware specific configuration parameters:
153153

154+
- "firewall.reverseProxy": Set to "true" when a reverse proxy is used ("")
155+
- "firewall.allowedIpAddresses": List of IP addresses that are allowed to connect ("")
154156
- "cors.allowedOrigins": The origins allowed in the CORS headers ("*")
155157
- "cors.allowHeaders": The headers allowed in the CORS request ("Content-Type, X-XSRF-TOKEN")
156158
- "cors.allowMethods": The methods allowed in the CORS request ("OPTIONS, GET, PUT, POST, DELETE, PATCH")
157159
- "cors.allowCredentials": To allow credentials in the CORS request ("true")
158160
- "cors.maxAge": The time that the CORS grant is valid in seconds ("1728000")
161+
- "jwtAuth.leeway": The acceptable number of seconds of clock skew ("5")
162+
- "jwtAuth.ttl": The number of seconds the token is valid ("30")
163+
- "jwtAuth.secret": The shared secret used to sign the JWT token with ("")
164+
- "basicAuth.passwordFile": The file to read for username/password combinations (".htpasswd")
159165
- "authorization.tableHandler": Handler to implement table authorization rules ("")
160166
- "authorization.columnHandler": Handler to implement column authorization rules ("")
161167
- "authorization.recordHandler": Handler to implement record authorization filter rules ("")
162-
- "basicAuth.passwordFile": The file to read for username/password combinations (".htpasswd")
163-
- "basicAuth.realm": Message shown when asking for credentials ("Username and password required")
164-
- "firewall.reverseProxy": Set to "true" when a reverse proxy is used ("")
165-
- "firewall.allowedIpAddresses": List of IP addresses that are allowed to connect ("")
166168
- "validation.handler": Handler to implement validation rules for input values ("")
167169
- "sanitation.handler": Handler to implement sanitation rules for input values ("")
168170

@@ -553,6 +555,44 @@ For spatial support there is an extra set of filters that can be applied on geom
553555

554556
These filters are based on OGC standards and so is the WKT specification in which the geometry columns are represented.
555557

558+
### Authentication
559+
560+
Authentication is done by means of sending a "Authorization" header. It identifies the user and stores this in the `$_SESSION` super global.
561+
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.
562+
Currently there are two types of authentication supported: "Basic" and "JWT".
563+
564+
#### Basic authentication
565+
566+
The Basic type supports a file that holds the users and their (hashed) passwords separated by a colon (':').
567+
When the passwords are entered in plain text they fill be automatically hashed.
568+
The authenticated username will be stored in the `$_SESSION['username']` variable.
569+
You need to send an "Authorization" header containing a base64 url encoded and colon separated username and password after the word "Basic".
570+
571+
Authorization: Basic dXNlcm5hbWUxOnBhc3N3b3JkMQ
572+
573+
This example sends the string "username1:password1".
574+
575+
#### JWT authentication
576+
577+
The JWT type requires another (SSO/Identity) server to sign a token that contains claims.
578+
Both servers share a secret so that they can either sign or verify that the signature is valid.
579+
Claims are stored in the `$_SESSION['claims']` variable.
580+
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).
581+
582+
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6IjE1MzgyMDc2MDUiLCJleHAiOjE1MzgyMDc2MzV9.Z5px_GT15TRKhJCTHhDt5Z6K6LRDSFnLj8U5ok9l7gw
583+
584+
This example sends the signed string:
585+
586+
{
587+
"sub": "1234567890",
588+
"name": "John Doe",
589+
"admin": true,
590+
"iat": "1538207605",
591+
"exp": 1538207635
592+
}
593+
594+
NB: The JWT implementation only supports the hash based algorithms HS256, HS384 and HS512.
595+
556596
### Authorizing tables, columns and records
557597

558598
By default all tables are reflected. If you want to restrict access to some tables you may add the 'authorization' middleware

src/Tqdev/PhpCrudApi/Api.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@
1111
use Tqdev\PhpCrudApi\Controller\Responder;
1212
use Tqdev\PhpCrudApi\Database\GenericDB;
1313
use Tqdev\PhpCrudApi\Middleware\AuthorizationMiddleware;
14+
use Tqdev\PhpCrudApi\Middleware\BasicAuthMiddleware;
1415
use Tqdev\PhpCrudApi\Middleware\CorsMiddleware;
1516
use Tqdev\PhpCrudApi\Middleware\FirewallMiddleware;
17+
use Tqdev\PhpCrudApi\Middleware\JwtAuthMiddleware;
1618
use Tqdev\PhpCrudApi\Middleware\Router\SimpleRouter;
1719
use Tqdev\PhpCrudApi\Middleware\SanitationMiddleware;
1820
use Tqdev\PhpCrudApi\Middleware\ValidationMiddleware;
@@ -51,6 +53,9 @@ public function __construct(Config $config)
5153
case 'basicAuth':
5254
new BasicAuthMiddleware($router, $responder, $properties);
5355
break;
56+
case 'jwtAuth':
57+
new JwtAuthMiddleware($router, $responder, $properties);
58+
break;
5459
case 'validation':
5560
new ValidationMiddleware($router, $responder, $properties, $reflection);
5661
break;

src/Tqdev/PhpCrudApi/Config.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ private function parseMiddlewares(array $values): array
8787
}
8888
}
8989
}
90-
$newValues['middlewares'] = $properties;
90+
$newValues['middlewares'] = array_reverse($properties, true);
9191
return $newValues;
9292
}
9393

src/Tqdev/PhpCrudApi/Middleware/BasicAuthMiddleware.php

Lines changed: 32 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
class BasicAuthMiddleware extends Middleware
1111
{
12-
private function isAllowed(String $username, String $password, array &$passwords): bool
12+
private function hasCorrectPassword(String $username, String $password, array &$passwords): bool
1313
{
1414
$hash = isset($passwords[$username]) ? $passwords[$username] : false;
1515
if ($hash && password_verify($password, $hash)) {
@@ -21,21 +21,12 @@ private function isAllowed(String $username, String $password, array &$passwords
2121
return false;
2222
}
2323

24-
private function authenticate(String $username, String $password, String $passwordFile): bool
24+
private function getValidUsername(String $username, String $password, String $passwordFile): String
2525
{
26-
if (session_status() == PHP_SESSION_NONE) {
27-
session_start();
28-
}
29-
if (isset($_SESSION['user']) && $_SESSION['user'] == $username) {
30-
return true;
31-
}
3226
$passwords = $this->readPasswords($passwordFile);
33-
$allowed = $this->isAllowed($username, $password, $passwords);
34-
if ($allowed) {
35-
$_SESSION['user'] = $username;
36-
}
27+
$valid = $this->hasCorrectPassword($username, $password, $passwords);
3728
$this->writePasswords($passwordFile, $passwords);
38-
return $allowed;
29+
return $valid ? $username : '';
3930
}
4031

4132
private function readPasswords(String $passwordFile): array
@@ -67,20 +58,36 @@ private function writePasswords(String $passwordFile, array $passwords): bool
6758
return $success;
6859
}
6960

61+
private function getAuthorizationCredentials(Request $request): String
62+
{
63+
$parts = explode(' ', trim($request->getHeader('Authorization')), 2);
64+
if (count($parts) != 2) {
65+
return '';
66+
}
67+
if ($parts[0] != 'Basic') {
68+
return '';
69+
}
70+
return base64_decode(strtr($parts[1], '-_', '+/'));
71+
}
72+
7073
public function handle(Request $request): Response
7174
{
72-
$username = isset($_SERVER['PHP_AUTH_USER']) ? $_SERVER['PHP_AUTH_USER'] : '';
73-
$password = isset($_SERVER['PHP_AUTH_PW']) ? $_SERVER['PHP_AUTH_PW'] : '';
74-
$passwordFile = $this->getProperty('passwordFile', '.htpasswd');
75-
if (!$username) {
76-
$response = $this->responder->error(ErrorCode::AUTHORIZATION_REQUIRED, $username);
77-
$realm = $this->getProperty('realm', 'Username and password required');
78-
$response->addHeader('WWW-Authenticate', "Basic realm=\"$realm\"");
79-
} elseif (!$this->authenticate($username, $password, $passwordFile)) {
80-
$response = $this->responder->error(ErrorCode::ACCESS_DENIED, $username);
81-
} else {
82-
$response = $this->next->handle($request);
75+
if (session_status() == PHP_SESSION_NONE) {
76+
session_start();
77+
}
78+
$credentials = $this->getAuthorizationCredentials($request);
79+
if ($credentials) {
80+
list($username, $password) = array('', '');
81+
if (strpos($credentials, ':') !== false) {
82+
list($username, $password) = explode(':', $credentials, 2);
83+
}
84+
$passwordFile = $this->getProperty('passwordFile', '.htpasswd');
85+
$validUser = $this->getValidUsername($username, $password, $passwordFile);
86+
$_SESSION['username'] = $validUser;
87+
if (!$validUser) {
88+
return $this->responder->error(ErrorCode::ACCESS_DENIED, $username);
89+
}
8390
}
84-
return $response;
91+
return $this->next->handle($request);
8592
}
8693
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
<?php
2+
namespace Tqdev\PhpCrudApi\Middleware;
3+
4+
use Tqdev\PhpCrudApi\Controller\Responder;
5+
use Tqdev\PhpCrudApi\Middleware\Base\Middleware;
6+
use Tqdev\PhpCrudApi\Record\ErrorCode;
7+
use Tqdev\PhpCrudApi\Request;
8+
use Tqdev\PhpCrudApi\Response;
9+
10+
class JwtAuthMiddleware extends Middleware
11+
{
12+
private function getVerifiedClaims(String $token, int $time, int $leeway, int $ttl, String $secret): array
13+
{
14+
$algorithms = array('HS256' => 'sha256', 'HS384' => 'sha384', 'HS512' => 'sha512');
15+
$token = explode('.', $token);
16+
if (count($token) < 3) {
17+
return array();
18+
}
19+
$header = json_decode(base64_decode(strtr($token[0], '-_', '+/')), true);
20+
if (!$secret) {
21+
return array();
22+
}
23+
if ($header['typ'] != 'JWT') {
24+
return array();
25+
}
26+
$algorithm = $header['alg'];
27+
if (!isset($algorithms[$algorithm])) {
28+
return array();
29+
}
30+
$hmac = $algorithms[$algorithm];
31+
$signature = bin2hex(base64_decode(strtr($token[2], '-_', '+/')));
32+
if ($signature != hash_hmac($hmac, "$token[0].$token[1]", $secret)) {
33+
return array();
34+
}
35+
$claims = json_decode(base64_decode(strtr($token[1], '-_', '+/')), true);
36+
if (!$claims) {
37+
return array();
38+
}
39+
if (isset($claims['nbf']) && $time + $leeway < $claims['nbf']) {
40+
return array();
41+
}
42+
if (isset($claims['iat']) && $time + $leeway < $claims['iat']) {
43+
return array();
44+
}
45+
if (isset($claims['exp']) && $time - $leeway > $claims['exp']) {
46+
return array();
47+
}
48+
if (isset($claims['iat']) && !isset($claims['exp'])) {
49+
if ($time - $leeway > $claims['iat'] + $ttl) {
50+
return array();
51+
}
52+
}
53+
return $claims;
54+
}
55+
56+
private function getClaims(String $token): array
57+
{
58+
$time = (int) $this->getProperty('time', time());
59+
$leeway = (int) $this->getProperty('leeway', '5');
60+
$ttl = (int) $this->getProperty('ttl', '30');
61+
$secret = $this->getProperty('secret', '');
62+
if (!$secret) {
63+
return array();
64+
}
65+
return $this->getVerifiedClaims($token, $time, $leeway, $ttl, $secret);
66+
}
67+
68+
private function getAuthorizationToken(Request $request): String
69+
{
70+
$parts = explode(' ', trim($request->getHeader('Authorization')), 2);
71+
if (count($parts) != 2) {
72+
return '';
73+
}
74+
if ($parts[0] != 'Bearer') {
75+
return '';
76+
}
77+
return $parts[1];
78+
}
79+
80+
public function handle(Request $request): Response
81+
{
82+
if (session_status() == PHP_SESSION_NONE) {
83+
session_start();
84+
}
85+
$token = $this->getAuthorizationToken($request);
86+
if ($token) {
87+
$claims = $this->getClaims($token);
88+
$_SESSION['claims'] = $claims;
89+
if (empty($claims)) {
90+
return $this->responder->error(ErrorCode::ACCESS_DENIED, 'JWT');
91+
}
92+
}
93+
return $this->next->handle($request);
94+
}
95+
}

test.php

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
include str_replace('\\', '/', "src\\$class.php");
99
});
1010

11-
function runDir(Api $api, String $dir, array $matches, String $category): array
11+
function runDir(Config $config, String $dir, array $matches, String $category): array
1212
{
1313
$success = 0;
1414
$total = 0;
@@ -27,10 +27,10 @@ function runDir(Api $api, String $dir, array $matches, String $category): array
2727
if (substr($entry, -4) != '.log') {
2828
continue;
2929
}
30-
$success += runTest($api, $file, $category);
30+
$success += runTest($config, $file, $category);
3131
$total += 1;
3232
} elseif (is_dir($file)) {
33-
$statistics = runDir($api, $file, array_slice($matches, 1), "$category/$entry");
33+
$statistics = runDir($config, $file, array_slice($matches, 1), "$category/$entry");
3434
$total += $statistics['total'];
3535
$success += $statistics['success'];
3636
}
@@ -39,7 +39,7 @@ function runDir(Api $api, String $dir, array $matches, String $category): array
3939
return compact('total', 'success', 'failed');
4040
}
4141

42-
function runTest(Api $api, String $file, String $category): int
42+
function runTest(Config $config, String $file, String $category): int
4343
{
4444
$title = ucwords(str_replace('_', ' ', $category)) . '/';
4545
$title .= ucwords(str_replace('_', ' ', substr(basename($file), 0, -4)));
@@ -61,6 +61,7 @@ function runTest(Api $api, String $file, String $category): int
6161
}
6262
$in = $parts[$i];
6363
$exp = $parts[$i + 1];
64+
$api = new Api($config);
6465
$out = $api->handle(Request::fromString($in));
6566
if ($recording) {
6667
$parts[$i + 1] = $out;
@@ -123,8 +124,7 @@ function run(array $drivers, String $dir, array $matches)
123124
$config = new Config($settings);
124125
loadFixture($dir, $config);
125126
$start = microtime(true);
126-
$api = new Api($config);
127-
$stats = runDir($api, "$dir/functional", $matches, '');
127+
$stats = runDir($config, "$dir/functional", $matches, '');
128128
$end = microtime(true);
129129
$time = ($end - $start) * 1000;
130130
$total = $stats['total'];

tests/config/.htpasswd

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
username1:$2y$10$Qov96xrFqrbaTu3e87SUD.ZH5MGrJ5q/xSDMoKxgZhK2H7TMNuVym

tests/config/base.php

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@
33
'database' => 'php-crud-api',
44
'username' => 'php-crud-api',
55
'password' => 'php-crud-api',
6-
'middlewares' => 'cors,authorization,validation,sanitation',
6+
'middlewares' => 'cors,jwtAuth,basicAuth,authorization,validation,sanitation',
7+
'jwtAuth.time' => '1538207605',
8+
'jwtAuth.secret' => 'axpIrCGNGqxzx2R9dtXLIPUSqPo778uhb8CA0F4Hx',
9+
'basicAuth.passwordFile' => __DIR__ . DIRECTORY_SEPARATOR . '.htpasswd',
710
'authorization.tableHandler' => function ($method, $path, $databaseName, $tableName) {
8-
return !($tableName == 'invisibles');
11+
return !($tableName == 'invisibles' && !isset($_SESSION['claims']['name']) && empty($_SESSION['username']));
912
},
1013
'authorization.columnHandler' => function ($method, $path, $databaseName, $tableName, $columnName) {
1114
return !($columnName == 'invisible');

0 commit comments

Comments
 (0)