diff --git a/.gitignore b/.gitignore index 2c1fc0c..cf73c05 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /vendor composer.phar composer.lock -.DS_Store \ No newline at end of file +.DS_Store +.php_cs.cache \ No newline at end of file diff --git a/.php_cs b/.php_cs new file mode 100644 index 0000000..5888b55 --- /dev/null +++ b/.php_cs @@ -0,0 +1,32 @@ +notPath('vendor') + ->in(__DIR__) + ->name('*.php') + ->notName('*.blade.php') + ->ignoreDotFiles(true) + ->ignoreVCS(true); + +return PhpCsFixer\Config::create() + ->setRules([ + '@Symfony' => true, + '@PSR2' => true, + 'array_syntax' => ['syntax' => 'short'], + 'ordered_imports' => ['sortAlgorithm' => 'length'], + 'no_unused_imports' => true, + 'blank_line_after_namespace' => true, + 'elseif' => true, + 'switch_case_space' => true, + 'ternary_operator_spaces' => true, + 'ternary_to_null_coalescing' => true, + 'binary_operator_spaces' => ['align_double_arrow' => false], + 'linebreak_after_opening_tag' => true, + 'not_operator_with_successor_space' => false, + 'phpdoc_order' => true, + 'phpdoc_align' => ['align' => 'left'], + 'concat_space'=> ['spacing' => 'one'], + 'new_with_braces' => false, + 'phpdoc_no_empty_return' => false, + ]) + ->setFinder($finder); \ No newline at end of file diff --git a/README.md b/README.md index 657ce43..2d0a6e6 100644 --- a/README.md +++ b/README.md @@ -273,5 +273,15 @@ $key = 'J1VFYTgUafp21ljEkanJYYnlY1j4REURXgAKzlwAUxABfCWPw4PBw9HKYbG4GWNvi125WUO0 $user->isKeyActive($key); ``` +## Test + +Run test with following command + +``` +vendor/bin/phpunit --testdox --verbose +``` + +## License +This package is open-sourced software licensed under the [MIT license](http://opensource.org/licenses/MIT) diff --git a/composer.json b/composer.json index f841464..7696d8c 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "kasitaw/api-key", - "description": "User defined api key to communicate with Kasitaw using machine-to-machine concept. Mostly for external integration", + "description": "User defined api key(using custom laravel guard) to enable client communicate with server for external integration in general", "keywords": ["kasitaw", "api-key", "api-integration", "api"], "require": { "php": ">=7", @@ -9,7 +9,8 @@ "require-dev": { "laravel/framework": "^6.0", "phpunit/phpunit": "^9.0", - "orchestra/testbench": "^5.1" + "orchestra/testbench": "^4.0", + "friendsofphp/php-cs-fixer": "^2.16" }, "autoload": { "psr-4": { @@ -28,6 +29,14 @@ ] } }, + "scripts": { + "format": [ + "vendor/bin/php-cs-fixer fix" + ], + "format-dry-run": [ + "vendor/bin/php-cs-fixer fix --dry-run --diff" + ] + }, "license": "MIT", "authors": [ { diff --git a/database/factories/ApiKeyFactory.php b/database/factories/ApiKeyFactory.php new file mode 100644 index 0000000..17db0f0 --- /dev/null +++ b/database/factories/ApiKeyFactory.php @@ -0,0 +1,20 @@ +define(ApiKey::class, function (Faker $faker) { + $user = factory(User::class)->create(); + + return [ + 'uuid' => Str::uuid()->toString(), + 'model_type' => get_class($user), + 'model_id' => $user->id, + 'key' => Str::random(80), + 'status' => true, + 'last_access_at' => now(), + ]; +}); diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php new file mode 100644 index 0000000..c319fbe --- /dev/null +++ b/database/factories/UserFactory.php @@ -0,0 +1,13 @@ +define(User::class, function (Faker $faker) { + return [ + 'name' => $faker->name, + 'created_at' => now(), + 'updated_at' => now(), + ]; +}); diff --git a/database/migrations/create_api_keys_table.php.stub b/database/migrations/create_api_keys_table.php.stub index 242776d..8b53f32 100644 --- a/database/migrations/create_api_keys_table.php.stub +++ b/database/migrations/create_api_keys_table.php.stub @@ -18,7 +18,7 @@ class CreateApiKeysTable extends Migration $table->morphs('model'); $table->text(config('api-key.columns.key')); $table->boolean('status')->default(true); - $table->timestamp('last_access_at'); + $table->timestamp('last_access_at')->nullable(); $table->timestamps(); $table->softDeletes(); }); diff --git a/phpunit.xml b/phpunit.xml index 042fe1c..14ab318 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,7 +1,6 @@ diff --git a/src/ApiKey.php b/src/ApiKey.php index f658a19..7923ee9 100644 --- a/src/ApiKey.php +++ b/src/ApiKey.php @@ -5,16 +5,24 @@ use Illuminate\Support\Str; use Kasitaw\ApiKey\Traits\HasApiKey; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\SoftDeletes; class ApiKey extends Model { use HasApiKey; + use SoftDeletes; public $incrementing = false; protected $guarded = []; - protected $dates = ['last_access_at']; + protected $primaryKey = 'uuid'; + + protected $keyType = 'string'; + + protected $dates = [ + 'last_access_at', + ]; protected $casts = [ 'status' => 'boolean', diff --git a/src/ApiKeyServiceProvider.php b/src/ApiKeyServiceProvider.php index 5a0f07f..9e8ffec 100644 --- a/src/ApiKeyServiceProvider.php +++ b/src/ApiKeyServiceProvider.php @@ -20,6 +20,8 @@ public function boot(Filesystem $filesystem) __DIR__ . '/../database/migrations/create_api_keys_table.php.stub' => $this->getMigrationFileName($filesystem), ], 'migrations'); + $this->loadRoutesFrom(__DIR__ . '/../tests/TestRoute/TestRoute.php'); + Auth::extend('api_key', function ($app, $name, array $config) { // Automatically build the DI, put it as reference $userProvider = app(UserTokenProvider::class); @@ -39,10 +41,6 @@ public function register() /** * Returns existing migration file if found, else uses the current timestamp. - * - * @param Filesystem $filesystem - * - * @return string */ protected function getMigrationFileName(Filesystem $filesystem): string { diff --git a/src/Guards/ApiGuard.php b/src/Guards/ApiGuard.php index bb08c00..ea33311 100644 --- a/src/Guards/ApiGuard.php +++ b/src/Guards/ApiGuard.php @@ -64,15 +64,16 @@ public function user() $token ); } - if ($apiKey) { $this->setUser($apiKey->authenticable); $apiKey->last_access_at = Carbon::now(); $apiKey->save(); + + return $apiKey->authenticable; } - return $apiKey->authenticable; + return null; } /** @@ -98,8 +99,6 @@ public function getTokenForRequest() /** * Validate a user's credentials. * - * @param array $credentials - * * @return bool */ public function validate(array $credentials = []) diff --git a/src/Traits/HasApiKey.php b/src/Traits/HasApiKey.php index 54a23aa..8441589 100644 --- a/src/Traits/HasApiKey.php +++ b/src/Traits/HasApiKey.php @@ -65,8 +65,6 @@ public function revokeKeyByUuid(...$uuid) /** * Generate new key. * - * @param bool $status - * * @return \Illuminate\Database\Eloquent\Model|string */ public function generateNewKey(bool $status = true) @@ -195,8 +193,6 @@ public function isKeyActive($keyOrUuid) /** * Base function to set the status. * - * @param bool $status - * * @return $this */ private function setStatusToAll(bool $status = true) @@ -211,8 +207,6 @@ private function setStatusToAll(bool $status = true) /** * Flatten the multi array into flat view. * - * @param array $input - * * @return array */ private function flatten(array $input) @@ -223,10 +217,6 @@ private function flatten(array $input) /** * Base method to set status(active or inactive) for some keys. * - * @param string $keyType - * @param array $keyOrUuid - * @param bool $status - * * @return bool */ private function setStatusToSome(string $keyType, array $keyOrUuid, bool $status = true) @@ -246,9 +236,6 @@ private function setStatusToSome(string $keyType, array $keyOrUuid, bool $status /** * Base method to delete the key. * - * @param string $keyType - * @param array $keyOrUuid - * * @return $this */ private function deleteKeys(string $keyType, array $keyOrUuid) diff --git a/src/UserTokenProvider.php b/src/UserTokenProvider.php index 5f28c14..e560363 100644 --- a/src/UserTokenProvider.php +++ b/src/UserTokenProvider.php @@ -28,6 +28,7 @@ public function retrieveByToken($identifier, $apiKey) return $this->apiKey ->with('authenticable') ->where($identifier, $apiKey) + ->where('status', true) ->first(); } diff --git a/tests/Feature/ApiKeyTest.php b/tests/Feature/ApiKeyTest.php new file mode 100644 index 0000000..eec7752 --- /dev/null +++ b/tests/Feature/ApiKeyTest.php @@ -0,0 +1,140 @@ +user = factory(User::class)->create(); + } + + public function test_can_access_route_using_active_api_key_with_authorization_header() + { + $apiKey = factory(ApiKey::class)->create([ + 'model_type' => get_class($this->user), + 'model_id' => $this->user->getKey(), + ]); + + $response = $this->withHeaders([ + 'Accept' => 'application/json', + 'Authorization' => "Bearer {$apiKey->key}", + ])->json('GET', '/authorize/user'); + + $response + ->assertStatus(200) + ->assertJson([ + 'name' => $this->user->name, + ]); + } + + public function test_can_access_route_using_active_api_key_with_query_param() + { + $apiKey = factory(ApiKey::class)->create([ + 'model_type' => get_class($this->user), + 'model_id' => $this->user->getKey(), + ]); + + $response = $this->withHeaders([ + 'Accept' => 'application/json', + ])->json('GET', "/authorize/user?api_key={$apiKey->key}"); + + $response + ->assertStatus(200) + ->assertJson([ + 'name' => $this->user->name, + ]); + } + + public function test_can_access_route_using_active_api_key_with_http_body() + { + $apiKey = factory(ApiKey::class)->create([ + 'model_type' => get_class($this->user), + 'model_id' => $this->user->getKey(), + ]); + + $response = $this->withHeaders([ + 'Accept' => 'application/json', + ])->json('GET', '/authorize/user', [ + 'api_key' => $apiKey->key, + ]); + + $response + ->assertStatus(200) + ->assertJson([ + 'name' => $this->user->name, + ]); + } + + public function test_cannot_access_route_using_inactive_api_key_with_authorization_header() + { + $apiKey = factory(ApiKey::class)->create([ + 'model_type' => get_class($this->user), + 'model_id' => $this->user->getKey(), + 'status' => false, + ]); + + $response = $this->withHeaders([ + 'Accept' => 'application/json', + 'Authorization' => "Bearer {$apiKey->key}", + ])->json('GET', '/unauthorized/user'); + + $response + ->assertStatus(401); + } + + public function test_cannot_access_route_using_inactive_api_key_with_query_param() + { + $apiKey = factory(ApiKey::class)->create([ + 'model_type' => get_class($this->user), + 'model_id' => $this->user->getKey(), + 'status' => false, + ]); + + $response = $this->withHeaders([ + 'Accept' => 'application/json', + ])->json('GET', "/unauthorized/user?api_key={$apiKey->key}"); + + $response + ->assertStatus(401); + } + + public function test_cannot_access_route_using_inactive_api_key_with_http_body() + { + $apiKey = factory(ApiKey::class)->create([ + 'model_type' => get_class($this->user), + 'model_id' => $this->user->getKey(), + 'status' => false, + ]); + + $response = $this->withHeaders([ + 'Accept' => 'application/json', + ])->json('GET', '/unauthorized/user', [ + 'api_key' => $apiKey->key, + ]); + + $response + ->assertStatus(401); + } + + public function test_cannot_access_route_if_not_passing_api_key() + { + $response = $this->withHeaders([ + 'Accept' => 'application/json', + ])->json('GET', '/unauthorized/user'); + + $response + ->assertStatus(401); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..46c57a9 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,67 @@ +withFactories(__DIR__ . '/../database/factories'); + + $this->setUpDatabase($this->app); + } + + protected function getPackageProviders($app) + { + return ['Kasitaw\ApiKey\ApiKeyServiceProvider']; + } + + /** + * @param \Illuminate\Foundation\Application $app + */ + protected function getEnvironmentSetUp($app) + { + config()->set('database.default', 'sqlite'); + config()->set('database.connections.sqlite', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + + config()->set('auth.guards.api_key', [ + 'driver' => 'api_key', + ]); + } + + /** + * @param \Illuminate\Foundation\Application $app + */ + protected function setUpDatabase($app) + { + $app['db']->connection()->getSchemaBuilder()->create('users', function (Blueprint $table) { + $table->increments('id'); + $table->string('name'); + $table->timestamps(); + }); + + $app['db']->connection()->getSchemaBuilder()->create('api_keys', function (Blueprint $table) { + $table->string('uuid'); + $table->morphs('model'); + $table->text('key'); + $table->boolean('status'); + $table->timestamp('last_access_at')->nullable(); + $table->softDeletes('deleted_at'); + $table->timestamps(); + }); + } +} diff --git a/tests/TestModel/User.php b/tests/TestModel/User.php new file mode 100644 index 0000000..3655539 --- /dev/null +++ b/tests/TestModel/User.php @@ -0,0 +1,10 @@ +user(); +})->middleware('auth:api_key'); + +Route::get('/unauthorized/user', function () { + return request()->user(); +})->middleware('auth:api_key'); diff --git a/tests/Unit/ApiKeyTest.php b/tests/Unit/ApiKeyTest.php new file mode 100644 index 0000000..2f6d9a0 --- /dev/null +++ b/tests/Unit/ApiKeyTest.php @@ -0,0 +1,336 @@ +user = factory(User::class)->create(); + } + + public function test_can_generate_api_key() + { + $apiKey = $this->user->generateNewKey(); + + $this->assertDatabaseHas('api_keys', ['key' => $apiKey->key]); + } + + public function test_can_revoke_all_api_keys() + { + factory(ApiKey::class, 3)->create([ + 'model_type' => get_class($this->user), + 'model_id' => $this->user->getKey(), + ]); + + $this->user->revokeAllKeys(); + + $this->user->api_keys->each(function ($item) { + $this->assertFalse($item->status); + }); + } + + public function test_can_revoke_key_by_key_with_single_key() + { + $apiKey = factory(ApiKey::class)->create([ + 'model_type' => get_class($this->user), + 'model_id' => $this->user->getKey(), + ]); + + $this->user->revokeKeyByKey($apiKey->key); + + $this->assertDatabaseHas('api_keys', [ + 'uuid' => $apiKey->uuid, + 'key' => $apiKey->key, + 'status' => false, + ]); + } + + public function test_can_revoke_key_by_key_with_multiple_key() + { + $apiKeys = factory(ApiKey::class, 3)->create([ + 'model_type' => get_class($this->user), + 'model_id' => $this->user->getKey(), + ]); + + $this->user->revokeKeyByKey($apiKeys[0]->key, $apiKeys[1]->key, $apiKeys[2]->key); + + $apiKeys->each(function ($item) { + $this->assertDatabaseHas('api_keys', [ + 'uuid' => $item->uuid, + 'key' => $item->key, + 'status' => false, + ]); + }); + } + + public function test_can_revoke_key_by_uuid_with_single_uuid() + { + $apiKey = factory(ApiKey::class)->create([ + 'model_type' => get_class($this->user), + 'model_id' => $this->user->getKey(), + ]); + + $this->user->revokeKeyByUuid($apiKey->uuid); + + $this->assertDatabaseHas('api_keys', [ + 'uuid' => $apiKey->uuid, + 'key' => $apiKey->key, + 'status' => false, + ]); + } + + public function test_can_revoke_key_by_uuid_with_multiple_uuid() + { + $apiKeys = factory(ApiKey::class, 3)->create([ + 'model_type' => get_class($this->user), + 'model_id' => $this->user->getKey(), + ]); + + $this->user->revokeKeyByUuid($apiKeys[0]->uuid, $apiKeys[1]->uuid, $apiKeys[2]->uuid); + + $apiKeys->each(function ($item) { + $this->assertDatabaseHas('api_keys', [ + 'uuid' => $item->uuid, + 'key' => $item->key, + 'status' => false, + ]); + }); + } + + public function test_can_activate_all_api_keys() + { + factory(ApiKey::class, 3)->create([ + 'model_type' => get_class($this->user), + 'model_id' => $this->user->getKey(), + ]); + + $this->user->activateAllKeys(); + + $this->user->api_keys->each(function ($item) { + $this->assertTrue($item->status); + }); + } + + public function test_can_activate_key_by_key_with_single_key() + { + $apiKey = factory(ApiKey::class)->create([ + 'model_type' => get_class($this->user), + 'model_id' => $this->user->getKey(), + ]); + + $this->user->activateKeyByKey($apiKey->key); + + $this->assertDatabaseHas('api_keys', [ + 'uuid' => $apiKey->uuid, + 'key' => $apiKey->key, + 'status' => true, + ]); + } + + public function test_can_activate_key_by_key_with_multiple_key() + { + $apiKeys = factory(ApiKey::class, 3)->create([ + 'model_type' => get_class($this->user), + 'model_id' => $this->user->getKey(), + ]); + + $this->user->activateKeyByKey($apiKeys[0]->key, $apiKeys[1]->key, $apiKeys[2]->key); + + $apiKeys->each(function ($item) { + $this->assertDatabaseHas('api_keys', [ + 'uuid' => $item->uuid, + 'key' => $item->key, + 'status' => true, + ]); + }); + } + + public function test_can_activate_key_by_uuid_with_single_uuid() + { + $apiKey = factory(ApiKey::class)->create([ + 'model_type' => get_class($this->user), + 'model_id' => $this->user->getKey(), + ]); + + $this->user->activateKeyByUuid($apiKey->uuid); + + $this->assertDatabaseHas('api_keys', [ + 'uuid' => $apiKey->uuid, + 'key' => $apiKey->key, + 'status' => true, + ]); + } + + public function test_can_activate_key_by_uuid_with_multiple_uuid() + { + $apiKeys = factory(ApiKey::class, 3)->create([ + 'model_type' => get_class($this->user), + 'model_id' => $this->user->getKey(), + ]); + + $this->user->activateKeyByUuid($apiKeys[0]->uuid, $apiKeys[1]->uuid, $apiKeys[2]->uuid); + + $apiKeys->each(function ($item) { + $this->assertDatabaseHas('api_keys', [ + 'uuid' => $item->uuid, + 'key' => $item->key, + 'status' => true, + ]); + }); + } + + public function test_can_remove_all_api_keys() + { + $apiKeys = factory(ApiKey::class, 3)->create([ + 'model_type' => get_class($this->user), + 'model_id' => $this->user->getKey(), + ]); + + $this->user->removeAllKeys(); + + $apiKeys->each(function ($item) { + $this->assertSoftDeleted('api_keys', [ + 'uuid' => $item->uuid, + 'key' => $item->key, + ]); + }); + } + + public function test_can_remove_api_key_with_key_with_single_key() + { + $apiKey = factory(ApiKey::class)->create([ + 'model_type' => get_class($this->user), + 'model_id' => $this->user->getKey(), + ]); + + $this->user->removeKeyByKey($apiKey->key); + + $this->assertSoftDeleted('api_keys', [ + 'uuid' => $apiKey->uuid, + 'key' => $apiKey->key, + ]); + } + + public function test_can_remove_api_key_with_key_with_multiple_key() + { + $apiKeys = factory(ApiKey::class, 3)->create([ + 'model_type' => get_class($this->user), + 'model_id' => $this->user->getKey(), + ]); + + $this->user->removeKeyByKey($apiKeys[0]->key, $apiKeys[1]->key, $apiKeys[2]->key); + + $apiKeys->each(function ($item) { + $this->assertSoftDeleted('api_keys', [ + 'uuid' => $item->uuid, + 'key' => $item->key, + ]); + }); + } + + public function test_can_remove_api_key_with_uuid_with_single_uuid() + { + $apiKey = factory(ApiKey::class)->create([ + 'model_type' => get_class($this->user), + 'model_id' => $this->user->getKey(), + ]); + + $this->user->removeKeyByUuid($apiKey->uuid); + + $this->assertSoftDeleted('api_keys', [ + 'uuid' => $apiKey->uuid, + 'key' => $apiKey->key, + ]); + } + + public function test_can_remove_api_key_with_uuid_with_multiple_uuid() + { + $apiKeys = factory(ApiKey::class, 3)->create([ + 'model_type' => get_class($this->user), + 'model_id' => $this->user->getKey(), + ]); + + $this->user->removeKeyByUuid($apiKeys[0]->uuid, $apiKeys[1]->uuid, $apiKeys[2]->uuid); + + $apiKeys->each(function ($item) { + $this->assertSoftDeleted('api_keys', [ + 'uuid' => $item->uuid, + 'key' => $item->key, + ]); + }); + } + + public function test_can_check_is_api_key_is_active_using_uuid() + { + $apiKey = factory(ApiKey::class)->create([ + 'model_type' => get_class($this->user), + 'model_id' => $this->user->getKey(), + ]); + + $actual = $this->user->isKeyActive($apiKey->uuid); + + $this->assertTrue($actual, true); + } + + public function test_can_check_is_api_key_is_active_using_key() + { + $apiKey = factory(ApiKey::class)->create([ + 'model_type' => get_class($this->user), + 'model_id' => $this->user->getKey(), + ]); + + $actual = $this->user->isKeyActive($apiKey->key); + + $this->assertTrue($actual, true); + } + + public function test_can_check_is_api_key_is_inactive_using_uuid() + { + $apiKey = factory(ApiKey::class)->create([ + 'model_type' => get_class($this->user), + 'model_id' => $this->user->getKey(), + 'status' => false, + ]); + + $actual = $this->user->isKeyActive($apiKey->uuid); + + $this->assertFalse($actual, false); + } + + public function test_can_check_is_api_key_is_inactive_using_key() + { + $apiKey = factory(ApiKey::class)->create([ + 'model_type' => get_class($this->user), + 'model_id' => $this->user->getKey(), + 'status' => false, + ]); + + $actual = $this->user->isKeyActive($apiKey->key); + + $this->assertFalse($actual, false); + } + + public function test_can_get_all_keys() + { + factory(ApiKey::class, 3)->create([ + 'model_type' => get_class($this->user), + 'model_id' => $this->user->getKey(), + ]); + + factory(ApiKey::class, 4)->create(); + + $this->assertSame($this->user->api_keys->count(), 3); + } +}