Browse Source

Add JWT and (better) basic auth

Maurits van der Schee 5 years ago
parent
commit
13e3e3efe4

+ 50
- 10
README.md View File

@@ -91,9 +91,8 @@ These limitation were also present in v1:
91 91
   - Composite primary or foreign keys are not supported
92 92
   - Complex writes (transactions) are not supported
93 93
   - 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
+  
97 96
 ## Features
98 97
 
99 98
 These features match features in v1 (see branch "v1"):
@@ -120,7 +119,7 @@ These features match features in v1 (see branch "v1"):
120 119
   - [x] Spatial/GIS fields and filters supported with WKT
121 120
   - [ ] Unstructured data support through JSON/JSONB
122 121
   - [ ] Generate API documentation using OpenAPI tools
123
-  - [ ] Authentication via JWT token or username/password
122
+  - [x] Authentication via JWT token or username/password
124 123
   - [ ] ~~SQLite support~~
125 124
 
126 125
  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.
141 140
 
142 141
 You can enable the following middleware using the "middlewares" config parameter:
143 142
 
143
+- "firewall": Limit access to specific IP addresses
144 144
 - "cors": Support for CORS requests (enabled by default)
145
-- "authorization": Restrict access to certain tables or columns
145
+- "jwtAuth": Support for "Basic Authentication"
146 146
 - "basicAuth": Support for "Basic Authentication"
147
-- "firewall": Limit access to specific IP addresses
147
+- "authorization": Restrict access to certain tables or columns
148 148
 - "validation": Return input validation errors for custom rules
149 149
 - "sanitation": Apply input sanitation on create and update
150 150
 
151 151
 The "middlewares" config parameter is a comma separated list of enabled middlewares.
152 152
 You can tune the middleware behavior using middleware specific configuration parameters:
153 153
 
154
+- "firewall.reverseProxy": Set to "true" when a reverse proxy is used ("")
155
+- "firewall.allowedIpAddresses": List of IP addresses that are allowed to connect ("")
154 156
 - "cors.allowedOrigins": The origins allowed in the CORS headers ("*")
155 157
 - "cors.allowHeaders": The headers allowed in the CORS request ("Content-Type, X-XSRF-TOKEN")
156 158
 - "cors.allowMethods": The methods allowed in the CORS request ("OPTIONS, GET, PUT, POST, DELETE, PATCH")
157 159
 - "cors.allowCredentials": To allow credentials in the CORS request ("true")
158 160
 - "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")
159 165
 - "authorization.tableHandler": Handler to implement table authorization rules ("")
160 166
 - "authorization.columnHandler": Handler to implement column authorization rules ("")
161 167
 - "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 ("")
166 168
 - "validation.handler": Handler to implement validation rules for input values ("")
167 169
 - "sanitation.handler": Handler to implement sanitation rules for input values ("")
168 170
 
@@ -553,6 +555,44 @@ For spatial support there is an extra set of filters that can be applied on geom
553 555
 
554 556
 These filters are based on OGC standards and so is the WKT specification in which the geometry columns are represented.
555 557
 
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
+
556 596
 ### Authorizing tables, columns and records
557 597
 
558 598
 By default all tables are reflected. If you want to restrict access to some tables you may add the 'authorization' middleware 

+ 5
- 0
src/Tqdev/PhpCrudApi/Api.php View File

@@ -11,8 +11,10 @@ use Tqdev\PhpCrudApi\Controller\RecordController;
11 11
 use Tqdev\PhpCrudApi\Controller\Responder;
12 12
 use Tqdev\PhpCrudApi\Database\GenericDB;
13 13
 use Tqdev\PhpCrudApi\Middleware\AuthorizationMiddleware;
14
+use Tqdev\PhpCrudApi\Middleware\BasicAuthMiddleware;
14 15
 use Tqdev\PhpCrudApi\Middleware\CorsMiddleware;
15 16
 use Tqdev\PhpCrudApi\Middleware\FirewallMiddleware;
17
+use Tqdev\PhpCrudApi\Middleware\JwtAuthMiddleware;
16 18
 use Tqdev\PhpCrudApi\Middleware\Router\SimpleRouter;
17 19
 use Tqdev\PhpCrudApi\Middleware\SanitationMiddleware;
18 20
 use Tqdev\PhpCrudApi\Middleware\ValidationMiddleware;
@@ -51,6 +53,9 @@ class Api
51 53
                 case 'basicAuth':
52 54
                     new BasicAuthMiddleware($router, $responder, $properties);
53 55
                     break;
56
+                case 'jwtAuth':
57
+                    new JwtAuthMiddleware($router, $responder, $properties);
58
+                    break;
54 59
                 case 'validation':
55 60
                     new ValidationMiddleware($router, $responder, $properties, $reflection);
56 61
                     break;

+ 1
- 1
src/Tqdev/PhpCrudApi/Config.php View File

@@ -87,7 +87,7 @@ class Config
87 87
                 }
88 88
             }
89 89
         }
90
-        $newValues['middlewares'] = $properties;
90
+        $newValues['middlewares'] = array_reverse($properties, true);
91 91
         return $newValues;
92 92
     }
93 93
 

+ 32
- 25
src/Tqdev/PhpCrudApi/Middleware/BasicAuthMiddleware.php View File

@@ -9,7 +9,7 @@ use Tqdev\PhpCrudApi\Response;
9 9
 
10 10
 class BasicAuthMiddleware extends Middleware
11 11
 {
12
-    private function isAllowed(String $username, String $password, array &$passwords): bool
12
+    private function hasCorrectPassword(String $username, String $password, array &$passwords): bool
13 13
     {
14 14
         $hash = isset($passwords[$username]) ? $passwords[$username] : false;
15 15
         if ($hash && password_verify($password, $hash)) {
@@ -21,21 +21,12 @@ class BasicAuthMiddleware extends Middleware
21 21
         return false;
22 22
     }
23 23
 
24
-    private function authenticate(String $username, String $password, String $passwordFile): bool
24
+    private function getValidUsername(String $username, String $password, String $passwordFile): String
25 25
     {
26
-        if (session_status() == PHP_SESSION_NONE) {
27
-            session_start();
28
-        }
29
-        if (isset($_SESSION['user']) && $_SESSION['user'] == $username) {
30
-            return true;
31
-        }
32 26
         $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);
37 28
         $this->writePasswords($passwordFile, $passwords);
38
-        return $allowed;
29
+        return $valid ? $username : '';
39 30
     }
40 31
 
41 32
     private function readPasswords(String $passwordFile): array
@@ -67,20 +58,36 @@ class BasicAuthMiddleware extends Middleware
67 58
         return $success;
68 59
     }
69 60
 
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
+
70 73
     public function handle(Request $request): Response
71 74
     {
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
+            }
83 90
         }
84
-        return $response;
91
+        return $this->next->handle($request);
85 92
     }
86 93
 }

+ 95
- 0
src/Tqdev/PhpCrudApi/Middleware/JwtAuthMiddleware.php View File

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

+ 6
- 6
test.php View File

@@ -8,7 +8,7 @@ spl_autoload_register(function ($class) {
8 8
     include str_replace('\\', '/', "src\\$class.php");
9 9
 });
10 10
 
11
-function runDir(Api $api, String $dir, array $matches, String $category): array
11
+function runDir(Config $config, String $dir, array $matches, String $category): array
12 12
 {
13 13
     $success = 0;
14 14
     $total = 0;
@@ -27,10 +27,10 @@ function runDir(Api $api, String $dir, array $matches, String $category): array
27 27
             if (substr($entry, -4) != '.log') {
28 28
                 continue;
29 29
             }
30
-            $success += runTest($api, $file, $category);
30
+            $success += runTest($config, $file, $category);
31 31
             $total += 1;
32 32
         } 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");
34 34
             $total += $statistics['total'];
35 35
             $success += $statistics['success'];
36 36
         }
@@ -39,7 +39,7 @@ function runDir(Api $api, String $dir, array $matches, String $category): array
39 39
     return compact('total', 'success', 'failed');
40 40
 }
41 41
 
42
-function runTest(Api $api, String $file, String $category): int
42
+function runTest(Config $config, String $file, String $category): int
43 43
 {
44 44
     $title = ucwords(str_replace('_', ' ', $category)) . '/';
45 45
     $title .= ucwords(str_replace('_', ' ', substr(basename($file), 0, -4)));
@@ -61,6 +61,7 @@ function runTest(Api $api, String $file, String $category): int
61 61
         }
62 62
         $in = $parts[$i];
63 63
         $exp = $parts[$i + 1];
64
+        $api = new Api($config);
64 65
         $out = $api->handle(Request::fromString($in));
65 66
         if ($recording) {
66 67
             $parts[$i + 1] = $out;
@@ -123,8 +124,7 @@ function run(array $drivers, String $dir, array $matches)
123 124
         $config = new Config($settings);
124 125
         loadFixture($dir, $config);
125 126
         $start = microtime(true);
126
-        $api = new Api($config);
127
-        $stats = runDir($api, "$dir/functional", $matches, '');
127
+        $stats = runDir($config, "$dir/functional", $matches, '');
128 128
         $end = microtime(true);
129 129
         $time = ($end - $start) * 1000;
130 130
         $total = $stats['total'];

+ 1
- 0
tests/config/.htpasswd View File

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

+ 5
- 2
tests/config/base.php View File

@@ -3,9 +3,12 @@ $settings = [
3 3
     'database' => 'php-crud-api',
4 4
     'username' => 'php-crud-api',
5 5
     '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',
7 10
     'authorization.tableHandler' => function ($method, $path, $databaseName, $tableName) {
8
-        return !($tableName == 'invisibles');
11
+        return !($tableName == 'invisibles' && !isset($_SESSION['claims']['name']) && empty($_SESSION['username']));
9 12
     },
10 13
     'authorization.columnHandler' => function ($method, $path, $databaseName, $tableName, $columnName) {
11 14
         return !($columnName == 'invisible');

+ 33
- 0
tests/functional/002_auth/001_jwt_auth.log View File

@@ -0,0 +1,33 @@
1
+GET /records/invisibles/e42c77c6-06a4-4502-816c-d112c7142e6d
2
+Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6IjE1MzgyMDc2MDUiLCJleHAiOjE1MzgyMDc2MzV9.Z5px_GT15TRKhJCTHhDt5Z6K6LRDSFnLj8U5ok9l7gw
3
+===
4
+200
5
+Content-Type: application/json
6
+Content-Length: 45
7
+
8
+{"id":"e42c77c6-06a4-4502-816c-d112c7142e6d"}
9
+===
10
+GET /records/invisibles/e42c77c6-06a4-4502-816c-d112c7142e6d
11
+===
12
+200
13
+Content-Type: application/json
14
+Content-Length: 45
15
+
16
+{"id":"e42c77c6-06a4-4502-816c-d112c7142e6d"}
17
+===
18
+GET /records/invisibles/e42c77c6-06a4-4502-816c-d112c7142e6d
19
+Authorization: Bearer invalid
20
+===
21
+403
22
+Content-Type: application/json
23
+Content-Length: 49
24
+
25
+{"code":1012,"message":"Access denied for 'JWT'"}
26
+===
27
+GET /records/invisibles/e42c77c6-06a4-4502-816c-d112c7142e6d
28
+===
29
+404
30
+Content-Type: application/json
31
+Content-Length: 54
32
+
33
+{"code":1001,"message":"Table 'invisibles' not found"}

+ 33
- 0
tests/functional/002_auth/002_basic_auth.log View File

@@ -0,0 +1,33 @@
1
+GET /records/invisibles/e42c77c6-06a4-4502-816c-d112c7142e6d
2
+Authorization: Basic dXNlcm5hbWUxOnBhc3N3b3JkMQ
3
+===
4
+200
5
+Content-Type: application/json
6
+Content-Length: 45
7
+
8
+{"id":"e42c77c6-06a4-4502-816c-d112c7142e6d"}
9
+===
10
+GET /records/invisibles/e42c77c6-06a4-4502-816c-d112c7142e6d
11
+===
12
+200
13
+Content-Type: application/json
14
+Content-Length: 45
15
+
16
+{"id":"e42c77c6-06a4-4502-816c-d112c7142e6d"}
17
+===
18
+GET /records/invisibles/e42c77c6-06a4-4502-816c-d112c7142e6d
19
+Authorization: Basic aW52YWxpZHVzZXI6aW52YWxpZHBhc3M
20
+===
21
+403
22
+Content-Type: application/json
23
+Content-Length: 57
24
+
25
+{"code":1012,"message":"Access denied for 'invaliduser'"}
26
+===
27
+GET /records/invisibles/e42c77c6-06a4-4502-816c-d112c7142e6d
28
+===
29
+404
30
+Content-Type: application/json
31
+Content-Length: 54
32
+
33
+{"code":1001,"message":"Table 'invisibles' not found"}

tests/functional/002_columns/001_get_database.log → tests/functional/003_columns/001_get_database.log View File


tests/functional/002_columns/002_get_barcodes_table.log → tests/functional/003_columns/002_get_barcodes_table.log View File


tests/functional/002_columns/003_get_barcodes_id_column.log → tests/functional/003_columns/003_get_barcodes_id_column.log View File


tests/functional/002_columns/004_update_barcodes_id_column.log → tests/functional/003_columns/004_update_barcodes_id_column.log View File


tests/functional/002_columns/005_update_barcodes_product_id_nullable.log → tests/functional/003_columns/005_update_barcodes_product_id_nullable.log View File


tests/functional/002_columns/006_update_events_visitors_pk.log → tests/functional/003_columns/006_update_events_visitors_pk.log View File


tests/functional/002_columns/007_update_barcodes_product_id_fk.log → tests/functional/003_columns/007_update_barcodes_product_id_fk.log View File


tests/functional/002_columns/008_update_barcodes_table.log → tests/functional/003_columns/008_update_barcodes_table.log View File


tests/functional/002_columns/009_update_barcodes_hex_type.log → tests/functional/003_columns/009_update_barcodes_hex_type.log View File


tests/functional/002_columns/010_create_barcodes_table.log → tests/functional/003_columns/010_create_barcodes_table.log View File


tests/functional/002_columns/011_create_barcodes_column.log → tests/functional/003_columns/011_create_barcodes_column.log View File


tests/functional/002_columns/012_get_invisibles_table.log → tests/functional/003_columns/012_get_invisibles_table.log View File


tests/functional/002_columns/013_get_invisible_column.log → tests/functional/003_columns/013_get_invisible_column.log View File


tests/functional/003_cache/001_clear_cache.log → tests/functional/004_cache/001_clear_cache.log View File


Loading…
Cancel
Save