From 13e3e3efe4fa2476b5d72e16a9c20cb5203c937e Mon Sep 17 00:00:00 2001 From: Maurits van der Schee Date: Sat, 29 Sep 2018 22:38:58 +0200 Subject: [PATCH] Add JWT and (better) basic auth --- README.md | 62 +++++++++--- src/Tqdev/PhpCrudApi/Api.php | 5 + src/Tqdev/PhpCrudApi/Config.php | 2 +- .../Middleware/BasicAuthMiddleware.php | 57 ++++++----- .../Middleware/JwtAuthMiddleware.php | 95 +++++++++++++++++++ test.php | 12 +-- tests/config/.htpasswd | 1 + tests/config/base.php | 7 +- tests/functional/002_auth/001_jwt_auth.log | 33 +++++++ tests/functional/002_auth/002_basic_auth.log | 33 +++++++ .../001_get_database.log | 0 .../002_get_barcodes_table.log | 0 .../003_get_barcodes_id_column.log | 0 .../004_update_barcodes_id_column.log | 0 ...05_update_barcodes_product_id_nullable.log | 0 .../006_update_events_visitors_pk.log | 0 .../007_update_barcodes_product_id_fk.log | 0 .../008_update_barcodes_table.log | 0 .../009_update_barcodes_hex_type.log | 0 .../010_create_barcodes_table.log | 0 .../011_create_barcodes_column.log | 0 .../012_get_invisibles_table.log | 0 .../013_get_invisible_column.log | 0 .../001_clear_cache.log | 0 24 files changed, 262 insertions(+), 45 deletions(-) create mode 100644 src/Tqdev/PhpCrudApi/Middleware/JwtAuthMiddleware.php create mode 100644 tests/config/.htpasswd create mode 100644 tests/functional/002_auth/001_jwt_auth.log create mode 100644 tests/functional/002_auth/002_basic_auth.log rename tests/functional/{002_columns => 003_columns}/001_get_database.log (100%) rename tests/functional/{002_columns => 003_columns}/002_get_barcodes_table.log (100%) rename tests/functional/{002_columns => 003_columns}/003_get_barcodes_id_column.log (100%) rename tests/functional/{002_columns => 003_columns}/004_update_barcodes_id_column.log (100%) rename tests/functional/{002_columns => 003_columns}/005_update_barcodes_product_id_nullable.log (100%) rename tests/functional/{002_columns => 003_columns}/006_update_events_visitors_pk.log (100%) rename tests/functional/{002_columns => 003_columns}/007_update_barcodes_product_id_fk.log (100%) rename tests/functional/{002_columns => 003_columns}/008_update_barcodes_table.log (100%) rename tests/functional/{002_columns => 003_columns}/009_update_barcodes_hex_type.log (100%) rename tests/functional/{002_columns => 003_columns}/010_create_barcodes_table.log (100%) rename tests/functional/{002_columns => 003_columns}/011_create_barcodes_column.log (100%) rename tests/functional/{002_columns => 003_columns}/012_get_invisibles_table.log (100%) rename tests/functional/{002_columns => 003_columns}/013_get_invisible_column.log (100%) rename tests/functional/{003_cache => 004_cache}/001_clear_cache.log (100%) diff --git a/README.md b/README.md index 5cd9097..537a330 100644 --- a/README.md +++ b/README.md @@ -91,9 +91,8 @@ 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 These features match features in v1 (see branch "v1"): @@ -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 diff --git a/src/Tqdev/PhpCrudApi/Api.php b/src/Tqdev/PhpCrudApi/Api.php index ca29047..0d56e3c 100644 --- a/src/Tqdev/PhpCrudApi/Api.php +++ b/src/Tqdev/PhpCrudApi/Api.php @@ -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; diff --git a/src/Tqdev/PhpCrudApi/Config.php b/src/Tqdev/PhpCrudApi/Config.php index 9507116..1e7d634 100644 --- a/src/Tqdev/PhpCrudApi/Config.php +++ b/src/Tqdev/PhpCrudApi/Config.php @@ -87,7 +87,7 @@ class Config } } } - $newValues['middlewares'] = $properties; + $newValues['middlewares'] = array_reverse($properties, true); return $newValues; } diff --git a/src/Tqdev/PhpCrudApi/Middleware/BasicAuthMiddleware.php b/src/Tqdev/PhpCrudApi/Middleware/BasicAuthMiddleware.php index bbf5c24..5966256 100644 --- a/src/Tqdev/PhpCrudApi/Middleware/BasicAuthMiddleware.php +++ b/src/Tqdev/PhpCrudApi/Middleware/BasicAuthMiddleware.php @@ -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'] : ''; - $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); + if (session_status() == PHP_SESSION_NONE) { + session_start(); } - return $response; + $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'); + $validUser = $this->getValidUsername($username, $password, $passwordFile); + $_SESSION['username'] = $validUser; + if (!$validUser) { + return $this->responder->error(ErrorCode::ACCESS_DENIED, $username); + } + } + return $this->next->handle($request); } } diff --git a/src/Tqdev/PhpCrudApi/Middleware/JwtAuthMiddleware.php b/src/Tqdev/PhpCrudApi/Middleware/JwtAuthMiddleware.php new file mode 100644 index 0000000..44ffd80 --- /dev/null +++ b/src/Tqdev/PhpCrudApi/Middleware/JwtAuthMiddleware.php @@ -0,0 +1,95 @@ + '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); + } +} diff --git a/test.php b/test.php index eca27bc..de3b194 100644 --- a/test.php +++ b/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']; diff --git a/tests/config/.htpasswd b/tests/config/.htpasswd new file mode 100644 index 0000000..e1a019a --- /dev/null +++ b/tests/config/.htpasswd @@ -0,0 +1 @@ +username1:$2y$10$Qov96xrFqrbaTu3e87SUD.ZH5MGrJ5q/xSDMoKxgZhK2H7TMNuVym diff --git a/tests/config/base.php b/tests/config/base.php index df66923..728dd96 100644 --- a/tests/config/base.php +++ b/tests/config/base.php @@ -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'); diff --git a/tests/functional/002_auth/001_jwt_auth.log b/tests/functional/002_auth/001_jwt_auth.log new file mode 100644 index 0000000..4b8d677 --- /dev/null +++ b/tests/functional/002_auth/001_jwt_auth.log @@ -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"} diff --git a/tests/functional/002_auth/002_basic_auth.log b/tests/functional/002_auth/002_basic_auth.log new file mode 100644 index 0000000..c984ca5 --- /dev/null +++ b/tests/functional/002_auth/002_basic_auth.log @@ -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"} diff --git a/tests/functional/002_columns/001_get_database.log b/tests/functional/003_columns/001_get_database.log similarity index 100% rename from tests/functional/002_columns/001_get_database.log rename to tests/functional/003_columns/001_get_database.log diff --git a/tests/functional/002_columns/002_get_barcodes_table.log b/tests/functional/003_columns/002_get_barcodes_table.log similarity index 100% rename from tests/functional/002_columns/002_get_barcodes_table.log rename to tests/functional/003_columns/002_get_barcodes_table.log diff --git a/tests/functional/002_columns/003_get_barcodes_id_column.log b/tests/functional/003_columns/003_get_barcodes_id_column.log similarity index 100% rename from tests/functional/002_columns/003_get_barcodes_id_column.log rename to tests/functional/003_columns/003_get_barcodes_id_column.log diff --git a/tests/functional/002_columns/004_update_barcodes_id_column.log b/tests/functional/003_columns/004_update_barcodes_id_column.log similarity index 100% rename from tests/functional/002_columns/004_update_barcodes_id_column.log rename to tests/functional/003_columns/004_update_barcodes_id_column.log diff --git a/tests/functional/002_columns/005_update_barcodes_product_id_nullable.log b/tests/functional/003_columns/005_update_barcodes_product_id_nullable.log similarity index 100% rename from tests/functional/002_columns/005_update_barcodes_product_id_nullable.log rename to tests/functional/003_columns/005_update_barcodes_product_id_nullable.log diff --git a/tests/functional/002_columns/006_update_events_visitors_pk.log b/tests/functional/003_columns/006_update_events_visitors_pk.log similarity index 100% rename from tests/functional/002_columns/006_update_events_visitors_pk.log rename to tests/functional/003_columns/006_update_events_visitors_pk.log diff --git a/tests/functional/002_columns/007_update_barcodes_product_id_fk.log b/tests/functional/003_columns/007_update_barcodes_product_id_fk.log similarity index 100% rename from tests/functional/002_columns/007_update_barcodes_product_id_fk.log rename to tests/functional/003_columns/007_update_barcodes_product_id_fk.log diff --git a/tests/functional/002_columns/008_update_barcodes_table.log b/tests/functional/003_columns/008_update_barcodes_table.log similarity index 100% rename from tests/functional/002_columns/008_update_barcodes_table.log rename to tests/functional/003_columns/008_update_barcodes_table.log diff --git a/tests/functional/002_columns/009_update_barcodes_hex_type.log b/tests/functional/003_columns/009_update_barcodes_hex_type.log similarity index 100% rename from tests/functional/002_columns/009_update_barcodes_hex_type.log rename to tests/functional/003_columns/009_update_barcodes_hex_type.log diff --git a/tests/functional/002_columns/010_create_barcodes_table.log b/tests/functional/003_columns/010_create_barcodes_table.log similarity index 100% rename from tests/functional/002_columns/010_create_barcodes_table.log rename to tests/functional/003_columns/010_create_barcodes_table.log diff --git a/tests/functional/002_columns/011_create_barcodes_column.log b/tests/functional/003_columns/011_create_barcodes_column.log similarity index 100% rename from tests/functional/002_columns/011_create_barcodes_column.log rename to tests/functional/003_columns/011_create_barcodes_column.log diff --git a/tests/functional/002_columns/012_get_invisibles_table.log b/tests/functional/003_columns/012_get_invisibles_table.log similarity index 100% rename from tests/functional/002_columns/012_get_invisibles_table.log rename to tests/functional/003_columns/012_get_invisibles_table.log diff --git a/tests/functional/002_columns/013_get_invisible_column.log b/tests/functional/003_columns/013_get_invisible_column.log similarity index 100% rename from tests/functional/002_columns/013_get_invisible_column.log rename to tests/functional/003_columns/013_get_invisible_column.log diff --git a/tests/functional/003_cache/001_clear_cache.log b/tests/functional/004_cache/001_clear_cache.log similarity index 100% rename from tests/functional/003_cache/001_clear_cache.log rename to tests/functional/004_cache/001_clear_cache.log