Repository: givebutter/laravel-keyable Branch: master Commit: a4e1d6fed5b0 Files: 34 Total size: 54.8 KB Directory structure: gitextract_r_rvj5r7/ ├── .gitignore ├── LICENSE.md ├── README.md ├── UPGRADING.md ├── composer.json ├── config/ │ └── keyable.php ├── database/ │ └── migrations/ │ └── 2019_04_09_225232_create_api_keys_table.php ├── src/ │ ├── Auth/ │ │ ├── AuthorizesKeyableRequests.php │ │ └── Keyable.php │ ├── Console/ │ │ └── Commands/ │ │ ├── DeleteApiKey.php │ │ ├── GenerateApiKey.php │ │ └── HashApiKeys.php │ ├── Events/ │ │ └── KeyableAuthenticated.php │ ├── Facades/ │ │ └── Keyable.php │ ├── Http/ │ │ └── Middleware/ │ │ ├── AuthenticateApiKey.php │ │ └── EnforceKeyableScope.php │ ├── Keyable.php │ ├── KeyableServiceProvider.php │ ├── Models/ │ │ └── ApiKey.php │ └── NewApiKey.php └── tests/ ├── Feature/ │ ├── AuthenticateApiKey.php │ ├── CompatibilityMode.php │ └── EnforceKeyableScope.php ├── Support/ │ ├── Account.php │ ├── Comment.php │ ├── CommentsController.php │ ├── Migrations/ │ │ └── create_test_tables.php │ ├── Post.php │ └── PostsController.php ├── TestCase.php └── Unit/ ├── Console/ │ └── Commands/ │ ├── DeleteApiKey.php │ ├── GenerateApiKey.php │ └── HashApiKeys.php └── Models/ └── ApiKeyTest.php ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ /vendor composer.phar composer.lock .DS_Store .php_cs.cache ================================================ FILE: LICENSE.md ================================================ The MIT License Copyright (c) Givebutter, Inc. https://givebutter.com Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Laravel Keyable Laravel Keyable is a package that allows you to add API Keys to any model. This allows you to associate incoming requests with their respective models. You can also use Policies to authorize requests. [![Latest Stable Version](https://poser.pugx.org/givebutter/laravel-keyable/v/stable)](https://packagist.org/packages/givebutter/laravel-keyable) [![Total Downloads](https://poser.pugx.org/givebutter/laravel-keyable/downloads)](https://packagist.org/packages/givebutter/laravel-keyable) [![License](https://poser.pugx.org/givebutter/laravel-keyable/license)](https://packagist.org/packages/givebutter/laravel-keyable) ## Installation Require the ```givebutter/laravel-keyable``` package in your ```composer.json``` and update your dependencies: ```bash composer require givebutter/laravel-keyable ``` Publish the migration and config files: ```bash php artisan vendor:publish --provider="Givebutter\LaravelKeyable\KeyableServiceProvider" ``` Run the migration: ```bash php artisan migrate ``` ## Usage Add the ```Givebutter\LaravelKeyable\Keyable``` trait to your model(s): ```php use Illuminate\Database\Eloquent\Model; use Givebutter\LaravelKeyable\Keyable; class Account extends Model { use Keyable; // ... } ``` Add the ```auth.apiKey``` middleware to the ```mapApiRoutes()``` function in your ```App\Providers\RouteServiceProvider``` file: ```php // ... protected function mapApiRoutes() { Route::prefix('api') ->middleware(['api', 'auth.apikey']) ->namespace($this->namespace . '\API') ->group(base_path('routes/api.php')); } // ... ``` The middleware will authenticate API requests, ensuring they contain an API key that is valid. ### Generating API keys You can generate new API keys by calling the `createApiKey()` method from the `Keyable` trait. When you do so, it returns an instance of `NewApiKey`, which is a simple class the contains the actual `ApiKey` instance that was just created, and also contains the plain text api key, which is the one you should use to authenticate requests. ```php $newApiKey = $keyable->createApiKey(); $newApiKey->plainTextApiKey // This is the key you should use to authenticate requests $newApiKey->apiKey // The instance of ApiKey just created ``` You can also manually create API keys without using the `createApiKey` from the `Keyable` trait, in that case, the instance you get back will have a property called `plainTextApikey` populated with the plain text API key. ```php $myApiKey = ApiKey::create([ 'keyable_id' => $account->getKey(), 'keyable_type' => Account::class, 'name' => 'My api key', ]); $myApiKey->plainTextApikey // Token to be used to authenticate requests ``` Keep in mind `plainTextApikey` will only be populated immediately after creating the key. ### Accessing keyable models in your controllers The model associated with the key will be attached to the incoming request as ```keyable```: ```php use App\Http\Controllers\Controller; class FooController extends Controller { public function index(Request $request) { $model = $request->keyable; // ... } } ``` Now you can use the keyable model to scope your associated API resources, for example: ```php return $model->foo()->get(); ``` ### Keys Without Models Sometimes you may not want to attach a model to an API key (if you wanted to have administrative access to your API). By default this functionality is turned off: ```php true ]; ``` ## Making Requests By default, laravel-keyable uses bearer tokens to authenticate requests. Attach the API key to the header of each request: ``` Authorization: Bearer ``` You can change where the API key is retrieved from by altering the setting in the `keyable.php` config file. Supported options are: `bearer`, `header`, and `parameter`. ```php 'header', 'key' => 'X-Authorization', ]; ``` Need to pass the key as a URL parameter? Set the mode to `parameter` and the key to the string you'll use in your URL: ```php 'parameter', 'key' => 'api_key' ]; ``` Now you can make requests like this: ```php https://example.com/api/posts?api_key= ``` ## Authorizing Requests Laravel offers a great way to perform [Authorization](https://laravel.com/docs/5.8/authorization) on incoming requests using Policies. However, they are limited to authenticated users. We replicate that functionality to let you authorize requests on any incoming model. To begin, add the `AuthorizesKeyableRequests` trait to your base `Controller.php` class: ```php posts()->find($post->id)); } } ``` Lastly, register your policies in `AuthServiceProvider.php`: ```php PostPolicy::class ]; public function boot(GateContract $gate) { // ... Keyable::registerKeyablePolicies($this->keyablePolicies); } } ``` In your controller, you can now authorize the request using the policy by calling `$this->authorizeKeyable(, )`: ```php authorizeKeyable('view', $post); // ... } } ``` ## Keyable Model Scoping When using implicit model binding, you may wish to scope the first model such that it must be a child of the keyable model. Consider an example where we have a post resource: ```php use App\Models\Post; Route::get('/posts/{post}', function (Post $post) { return $post; }); ``` You may instruct the package to apply the scope by invoking the `keyableScoped` method when defining your route: ```php use App\Models\Post; Route::get('/posts/{post}', function (Post $post) { return $post; })->keyableScoped(); ``` The benefits of applying this scope are two-fold. First, models not belonging to the keyable model are caught before the controller. That means you don't have to handle this repeatedly in the controller methods. Second, models that don't belong to the keyable model will trigger a 404 response instead of a 403, keeping information hidden about other users. You may use this in tandem with Laravel's scoping to ensure the entire heirarchy has a parent-child relationship starting with the keyable model: ```php use App\Models\Post; use App\Models\User; Route::get('/users/{user}/posts/{post}', function (User $user, Post $post) { return $post; })->scopeBindings()->keyableScoped(); ``` ## Artisan Commands Generate an API key: ```bash php artisan api-key:generate --id=1 --type="App\Models\Account" --name="My api key" ``` Delete an API key: ```bash php artisan api-key:delete --id=12345 ``` ## Upgrading Please see [UPGRADING](UPGRADING.md) for details. ## Security If you discover any security related issues, please email [liran@givebutter.com](mailto:liran@givebutter.com). ## License Released under the [MIT](https://choosealicense.com/licenses/mit/) license. See [LICENSE](LICENSE.md) for more information. ================================================ FILE: UPGRADING.md ================================================ ## Upgrade guide ### From 2.1.1 to 3.0.0 ATTENTION: It is highly recommended that you generate a backup of your database before going through the steps below, just to be safe in case something goes wrong. #### Step 1: `api_keys` table updates Implement the following changes on your `api_keys` table. - Add a new nullable string column called `name`. - Modify the existing `key` column to increase its length from 40 to 64. #### Step 2: Update the package to version 3.0.0 ```bash composer require givebutter/laravel-keyable:3.0.0 ``` #### Step 3. Turn on `compatibility_mode` A new configuration flag was introduced in the `keyable.php` config file on version `3.0.0`, it is called `compatibility_mode`, make sure to publish the package's config file to be able to access it. By default it is set to `false`, but when it is set to `true` the package will handle both hashed and non hashed API keys, which should keep your application running smoothly while you complete all upgrade steps. It is specially useful if you have a very large `api_keys` table, which could take a while to hash all existing API keys. It points to an environment variable called `KEYABLE_COMPATIBILITY_MODE`, but you can update it to whatever you need of course. Make sure to update `KEYABLE_COMPATIBILITY_MODE` to `true` if you want to make use of that feature. #### Step 4. Hash existing API keys A command was added to hash existing API keys that are not currently hashed, it will ensure existing API keys will continue working properly once you finish all upgrade steps. ```bash php artisan api-key:hash ``` It is also possible to hash a single API key at a time, by passing an `--id` option. ```bash php artisan api-key:hash --id=API_KEY_ID ``` Be very careful with this option, as each API key should be hashed only once. Ideally you should only use it for testing and on your own API keys. The command tries to avoid hashing an API key twice by comparing the length of the `key` column, if it is already 64 then the command understands the key is already hashed and won't do it again. #### Step 5. Turn off compatibility mode If you are making use of the compatibility mode, it can now be turned off by setting `KEYABLE_COMPATIBILITY_MODE` to `false`, it is not needed anymore. ================================================ FILE: composer.json ================================================ { "name": "givebutter/laravel-keyable", "description": "Add API keys to your Laravel models", "license": "MIT", "keywords": [ "laravel", "php", "api", "rest", "json", "api keys", "api authentication" ], "homepage": "https://github.com/givebutter/laravel-keyable", "authors": [ { "name": "Liran Cohen", "email": "liran@givebutter.com" } ], "minimum-stability": "dev", "prefer-stable": true, "require": { "php": "^7.0|^8.0" }, "autoload": { "psr-4": { "Givebutter\\LaravelKeyable\\": "src/" } }, "autoload-dev": { "psr-4": { "Givebutter\\Tests\\": "tests/" } }, "extra": { "laravel": { "providers": [ "Givebutter\\LaravelKeyable\\KeyableServiceProvider" ] } }, "require-dev": { "phpunit/phpunit": "^9.5", "orchestra/testbench": "^8.0" } } ================================================ FILE: config/keyable.php ================================================ 'bearer', 'key' => null, /* |-------------------------------------------------------------------------- | Empty Models |-------------------------------------------------------------------------- | | Set this to true to allow API keys without an associated model. | */ 'allow_empty_models' => false, /* |-------------------------------------------------------------------------- | Compatibility mode |-------------------------------------------------------------------------- | | Set this to true to instruct this package to accept both hashed and non | hashed API keys. | | This is useful to keep your app running smoothly while you are going | throught the upgrade steps for version 2.1.1 to 3.0.0, especially if you | have a very large api_keys table, which can take a while to hash all | existing API keys. | | Once the new database changes are in place and all existing keys are | hashed, you should set this flag to false to instruct this package to | only look for hashed API keys. | */ 'compatibility_mode' => env('KEYABLE_COMPATIBILITY_MODE', false), ]; ================================================ FILE: database/migrations/2019_04_09_225232_create_api_keys_table.php ================================================ increments('id'); $table->nullableMorphs('keyable'); $table->string('name')->nullable(); $table->string('key', 64); $table->dateTime('last_used_at')->nullable(); $table->timestamps(); $table->softDeletes(); $table->index('key'); }); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::dropIfExists('api_keys'); } } ================================================ FILE: src/Auth/AuthorizesKeyableRequests.php ================================================ apiKey; $keyable = request()->keyable; if ($policy = $this->getKeyablePolicy($object)) { $policyClass = (new $policy()); if (method_exists($policyClass, 'before')) { $before = $policyClass->before($apiKey, $keyable, $object); if (! is_null($before) && $before) { return new Response(''); } } if ($policyClass->$ability($apiKey, $keyable, $object)) { return new Response(''); } } //Throw exception throw new AuthorizationException('This action is unauthorized.'); } /** * Get the associated policy. * * @return policy */ public function getKeyablePolicy($object) { return Keyable::getKeyablePolicies()[get_class($object)] ?? null; } } ================================================ FILE: src/Auth/Keyable.php ================================================ policies = $policies; } public function getKeyablePolicies() { return $this->policies; } } ================================================ FILE: src/Console/Commands/DeleteApiKey.php ================================================ option('id')); $key->delete(); $this->info('API key successfully deleted.'); } } ================================================ FILE: src/Console/Commands/GenerateApiKey.php ================================================ create([ 'keyable_id' => $this->option('id'), 'keyable_type' => $this->option('type'), 'name' => $this->option('name'), ]); $this->info('The following API key was created: ' . "{$apiKey->getKey()}|{$apiKey->plainTextApiKey}"); } } ================================================ FILE: src/Console/Commands/HashApiKeys.php ================================================ withTrashed() ->when($this->option('id'), function (Builder $query, int $id) { $query->where('id', $id); }) ->whereRaw('LENGTH(api_keys.key) != 64') ->eachById(function (ApiKey $apiKey) { $apiKey->update([ 'key' => hash('sha256', $apiKey->key), ]); $this->info("API key #{$apiKey->getKey()} successfully hashed."); }, 250); $this->info('All API keys were successfully hashed.'); }); } } ================================================ FILE: src/Events/KeyableAuthenticated.php ================================================ missing($param)) { continue; } $message = "Request param '{$param}' is not allowed."; if ($request->wantsJson()) { return response()->json(['message' => $message], 400); } return response($message, 400); } //Get API token from request $token = $this->getKeyFromRequest($request); //Check for presence of key if (! $token) { return $this->unauthorizedResponse(); } //Get API key $apiKey = ApiKey::getByKey($token); //Validate key if (! ($apiKey instanceof ApiKey)) { return $this->unauthorizedResponse(); } //Get the model $keyable = $apiKey->keyable; //Validate model if (config('keyable.allow_empty_models', false)) { if (! $keyable && (! is_null($apiKey->keyable_type) || ! is_null($apiKey->keyable_id))) { return $this->unauthorizedResponse(); } } else { if (! $keyable) { return $this->unauthorizedResponse(); } } //Attach the apikey object to the request $request->merge(['apiKey' => $apiKey]); if ($keyable) { $request->merge(['keyable' => $keyable]); } //Update last_used_at $apiKey->markAsUsed(); event(new KeyableAuthenticated($apiKey)); //Return return $next($request); } protected function getKeyFromRequest($request) { $mode = config('keyable.mode', 'bearer'); switch ($mode) { case 'bearer': return $request->bearerToken(); break; case 'header': return $request->header(config('keyable.key', 'X-Authorization')); break; case 'parameter': return $request->input(config('keyable.key', 'api_key')); break; } } protected function unauthorizedResponse() { return response([ 'error' => [ 'message' => 'Unauthorized', ], ], 401); } } ================================================ FILE: src/Http/Middleware/EnforceKeyableScope.php ================================================ route(); if (empty($route->parameterNames())) { return $next($request); } $parameterName = $route->parameterNames()[0]; $parameterValue = $route->originalParameters()[$parameterName]; $parameter = Arr::first($route->signatureParameters(UrlRoutable::class)); $instance = app(Reflector::getParameterClassName($parameter)); $childRouteBindingMethod = $route->allowsTrashedBindings() ? 'resolveSoftDeletableChildRouteBinding' : 'resolveChildRouteBinding'; if (! $request->keyable->{$childRouteBindingMethod}( $parameterName, $parameterValue, $route->bindingFieldFor($parameterName) )) { throw (new ModelNotFoundException)->setModel(get_class($instance), [$parameterValue]); } return $next($request); } protected static function getParameterName($name, $parameters) { if (array_key_exists($name, $parameters)) { return $name; } if (array_key_exists($snakedName = Str::snake($name), $parameters)) { return $snakedName; } } } ================================================ FILE: src/Keyable.php ================================================ morphMany(ApiKey::class, 'keyable'); } public function createApiKey(array $attributes = []): NewApiKey { $planTextApiKey = ApiKey::generate(); $apiKey = Model::withoutEvents(function () use ($planTextApiKey, $attributes) { return $this->apiKeys()->create([ 'key' => hash('sha256', $planTextApiKey), 'name' => $attributes['name'] ?? null, ]); }); return new NewApiKey($apiKey, "{$apiKey->getKey()}|{$planTextApiKey}"); } } ================================================ FILE: src/KeyableServiceProvider.php ================================================ publishes([ __DIR__ . '/../config/keyable.php' => config_path('keyable.php'), ]); $this->loadMigrationsFrom(__DIR__ . '/../database/migrations'); $this->registerMiddleware($router); $this->registerCommands(); $this->registerMacros(); } /** * Register services. * * @return void */ public function register() { // } protected function registerCommands() { if ($this->app->runningInConsole()) { $this->commands([ GenerateApiKey::class, DeleteApiKey::class, HashApiKeys::class, ]); } } /** * Register middleware. * * Support added for different Laravel versions * * @param Router $router */ protected function registerMiddleware(Router $router) { $versionComparison = version_compare(app()->version(), '5.4.0'); if ($versionComparison >= 0) { $router->aliasMiddleware('auth.apikey', AuthenticateApiKey::class); $router->aliasMiddleware('keyableScoped', EnforceKeyableScope::class); } else { $router->middleware('auth.apikey', AuthenticateApiKey::class); $router->middleware('keyableScoped', EnforceKeyableScope::class); } } protected function registerMacros() { PendingResourceRegistration::macro('keyableScoped', function () { $this->middleware('keyableScoped'); return $this; }); Route::macro('keyableScoped', function () { $this->middleware('keyableScoped'); return $this; }); } } ================================================ FILE: src/Models/ApiKey.php ================================================ 'datetime', ]; public static function boot() { parent::boot(); static::creating(function (ApiKey $apiKey) { if (is_null($apiKey->key)) { $apiKey->plainTextApiKey = self::generate(); $apiKey->key = hash('sha256', $apiKey->plainTextApiKey); } }); } /** * @return \Illuminate\Database\Eloquent\Relations\MorphTo */ public function keyable() { return $this->morphTo(); } /** * Generate a secure unique API key. * * @return string */ public static function generate() { do { $key = Str::random(40); } while (self::keyExists($key)); return $key; } /** * Get ApiKey record by key value. * * @param string $key * * @return bool */ public static function getByKey($key) { return self::ofKey($key)->first(); } /** * Check if a key already exists. * * Includes soft deleted records * * @param string $key * * @return bool */ public static function keyExists($key) { return self::ofKey($key) ->withTrashed() ->first() instanceof self; } /** * Mark key as used. */ public function markAsUsed() { return $this->forceFill([ 'last_used_at' => $this->freshTimestamp() ])->save(); } public function scopeOfKey(Builder $query, string $key): Builder { $compatibilityMode = config('keyable.compatibility_mode', false); if ($compatibilityMode) { return $query->where(function (Builder $query) use ($key) { if (! str_contains($key, '|')) { return $query->where('key', $key) ->orWhere('key', hash('sha256', $key)); } [$id, $key] = explode('|', $key, 2); return $query ->where(function (Builder $query) use ($key, $id) { return $query->where('key', $key) ->where('id', $id); }) ->orWhere(function (Builder $query) use ($key, $id) { return $query->where('key', hash('sha256', $key)) ->where('id', $id); }); }); } if (! str_contains($key, '|')) { return $query->where('key', hash('sha256', $key)); } [$id, $key] = explode('|', $key, 2); return $query->where('id', $id) ->where('key', hash('sha256', $key)); } } ================================================ FILE: src/NewApiKey.php ================================================ middleware(['api', 'auth.apikey']); $account = Account::create(); $this->withHeaders([ 'Authorization' => 'Bearer ' . $account->createApiKey()->plainTextApiKey, ])->get("/api/posts")->assertOk(); } /** @test */ public function request_with_valid_api_key_without_id_prefix_responds_ok() { Route::get("/api/posts", function () { return response('All good', 200); })->middleware(['api', 'auth.apikey']); $account = Account::create(); $plainTextApiKey = $account->createApiKey()->plainTextApiKey; [$id, $apiKeyWithoutIdPrefix] = explode('|', $plainTextApiKey); $this->assertEquals("{$id}|{$apiKeyWithoutIdPrefix}", $plainTextApiKey); $this->withHeaders([ 'Authorization' => 'Bearer ' . $apiKeyWithoutIdPrefix, ])->get("/api/posts")->assertOk(); } /** @test */ public function request_having_api_key_with_valid_but_mismatched_id_and_key_responds_unauthorized() { Route::get("/api/posts", function () { return response('All good', 200); })->middleware(['api', 'auth.apikey']); $account = Account::create(); $apiKey1 = $account->createApiKey(); $apiKey2 = $account->createApiKey(); $this->assertDatabaseHas('api_keys', [ 'id' => $apiKey1->apiKey->id, ]); $this->assertDatabaseHas('api_keys', [ 'id' => $apiKey2->apiKey->id, ]); $idFromApiKey1 = explode('|', $apiKey1->plainTextApiKey)[0]; $keyFromApiKey2 = explode('|', $apiKey2->plainTextApiKey)[1]; $mismatchedApiKey = "{$idFromApiKey1}|{$keyFromApiKey2}"; $this->assertNotEquals($mismatchedApiKey, $apiKey1->plainTextApiKey); $this->assertNotEquals($mismatchedApiKey, $apiKey2->plainTextApiKey); $this->withHeaders([ 'Authorization' => 'Bearer ' . $mismatchedApiKey, ])->get("/api/posts")->assertUnauthorized(); } /** @test */ public function request_without_api_key_responds_unauthorized() { Route::get("/api/posts", function () { return response('All good', 200); })->middleware(['api', 'auth.apikey']); $this->get("/api/posts")->assertUnauthorized(); } /** * @test * @dataProvider forbiddenRequestParams */ public function throw_exception_if_unauthorized_get_request_has_forbidden_request_query_params(string $queryParam): void { Route::get('/api/posts', function () { return response('All good', 200); })->middleware(['api', 'auth.apikey']); $this->get("/api/posts?{$queryParam}=value") ->assertBadRequest() ->assertContent("Request param '{$queryParam}' is not allowed."); } /** * @test * @dataProvider forbiddenRequestParams */ public function throw_exception_if_unauthorized_post_request_has_forbidden_request_body_params(string $bodyParam): void { Route::post('/api/posts', function () { return response('All good', 200); })->middleware(['api', 'auth.apikey']); $this->post('/api/posts', [$bodyParam => 'value']) ->assertBadRequest() ->assertContent("Request param '{$bodyParam}' is not allowed."); } /** * @test * @dataProvider forbiddenRequestParams */ public function throw_exception_if_unauthorized_json_get_request_has_forbidden_request_query_params(string $queryParam): void { Route::get('/api/posts', function () { return response('All good', 200); })->middleware(['api', 'auth.apikey']); $this->getJson("/api/posts?{$queryParam}=value") ->assertBadRequest() ->assertJson(['message' => "Request param '{$queryParam}' is not allowed."]); } /** * @test * @dataProvider forbiddenRequestParams */ public function throw_exception_if_unauthorized_json_post_request_has_forbidden_request_body_params(string $bodyParam): void { Route::post('/api/posts', function () { return response('All good', 200); })->middleware(['api', 'auth.apikey']); $this->postJson('/api/posts', [$bodyParam => 'value']) ->assertBadRequest() ->assertJson(['message' => "Request param '{$bodyParam}' is not allowed."]); } public function forbiddenRequestParams(): array { return [ ['keyable'], ['apiKey'], ]; } } ================================================ FILE: tests/Feature/CompatibilityMode.php ================================================ middleware(['api', 'auth.apikey'])->keyableScoped(); $account = Account::create(); $post = $account->posts()->create(); // Store the first api key as non hashed $plainTextApiKey1 = ApiKey::generate(); $apiKey1 = Model::withoutEvents(function () use ($plainTextApiKey1, $account) { return ApiKey::create([ 'keyable_id' => $account->getKey(), 'keyable_type' => Account::class, 'key' => $plainTextApiKey1, ]); }); // Store the second api key as non hashed $plainTextApiKey2 = ApiKey::generate(); $apiKey2 = Model::withoutEvents(function () use ($plainTextApiKey2, $account) { return ApiKey::create([ 'keyable_id' => $account->getKey(), 'keyable_type' => Account::class, 'key' => $plainTextApiKey2, ]); }); $this->assertDatabaseCount('api_keys', 2); $this->assertDatabaseHas('api_keys', [ 'id' => $apiKey1->getKey(), 'key' => $plainTextApiKey1, ]); $this->assertDatabaseHas('api_keys', [ 'id' => $apiKey2->getKey(), 'key' => $plainTextApiKey2, ]); // Ensure compatibility mode is on Config::set('keyable.compatibility_mode', true); // Hash only the second api key $this->artisan('api-key:hash', [ '--id' => $apiKey2->getKey(), ]); $this->assertDatabaseCount('api_keys', 2); $this->assertDatabaseHas('api_keys', [ 'id' => $apiKey1->getKey(), 'key' => $plainTextApiKey1, ]); $this->assertDatabaseHas('api_keys', [ 'id' => $apiKey2->getKey(), 'key' => $apiKey2->fresh()->key, ]); // Assert that non hashed api keys works $this->withHeaders([ 'Authorization' => "Bearer {$plainTextApiKey1}", ])->get("/api/posts/{$post->id}")->assertOk(); // Assert that non hashed api keys with ID prefix works $this->withHeaders([ 'Authorization' => "Bearer {$apiKey1->id}|{$plainTextApiKey1}", ])->get("/api/posts/{$post->id}")->assertOk(); // Assert that hashed api keys works $this->withHeaders([ 'Authorization' => "Bearer {$plainTextApiKey2}", ])->get("/api/posts/{$post->id}")->assertOk(); // Assert that hashed api keys with ID prefix works $this->withHeaders([ 'Authorization' => "Bearer {$apiKey2->id}|{$plainTextApiKey2}", ])->get("/api/posts/{$post->id}")->assertOk(); } } ================================================ FILE: tests/Feature/EnforceKeyableScope.php ================================================ middleware(['api', 'auth.apikey'])->keyableScoped(); $account = Account::create(); $post = $account->posts()->create(); $this->withHeaders([ 'Authorization' => 'Bearer ' . $account->createApiKey()->plainTextApiKey, ])->get("/api/posts/{$post->id}")->assertOk(); } /** @test */ public function request_with_model_not_owned_by_keyable_throws_model_not_found() { Route::get("/api/posts/{post}", function (Request $request, Post $post) { return response('All good', 200); })->middleware([ 'api', 'auth.apikey'])->keyableScoped(); $account = Account::create(); $account2 = Account::create(); $post = $account2->posts()->create(); $this->withHeaders([ 'Authorization' => 'Bearer ' . $account->createApiKey()->plainTextApiKey, ])->get("/api/posts/{$post->id}")->assertNotFound(); } /** @test */ public function works_with_resource_routes() { Route::prefix('api')->middleware(['api', 'auth.apikey'])->group(function () { Route::apiResource('posts', PostsController::class) ->only('show') ->keyableScoped(); }); /* | -------------------------------- | PASSING | -------------------------------- */ $account = Account::create(); $post = $account->posts()->create(); $this->withHeaders([ 'Authorization' => 'Bearer ' . $account->createApiKey()->plainTextApiKey, ])->get("/api/posts/{$post->id}")->assertOk(); /* | -------------------------------- | FAILING | -------------------------------- */ $account2 = Account::create(); $post = $account2->posts()->create(); $this->withHeaders([ 'Authorization' => 'Bearer ' . $account->createApiKey()->plainTextApiKey, ])->get("/api/posts/{$post->id}")->assertNotFound(); } /** @test */ public function can_use_scoped_with_keyableScoped() { Route::middleware(['api', 'auth.apikey'])->group(function () { Route::apiResource('posts.comments', CommentsController::class) ->only('show') ->scoped() ->keyableScoped(); }); /* | -------------------------------- | PASSING | -------------------------------- */ $account = Account::create(); $post = $account->posts()->create(); $comment = $post->comments()->create(); $this->withHeaders([ 'Authorization' => 'Bearer ' . $account->createApiKey()->plainTextApiKey, ])->get("posts/{$post->id}/comments/{$comment->id}")->assertOk(); /* | -------------------------------- | FAILING | -------------------------------- */ $account2 = Account::create(); $post2 = $account2->posts()->create(); $comment2 = $post2->comments()->create(); $this->withHeaders([ 'Authorization' => 'Bearer ' . $account->createApiKey()->plainTextApiKey, ])->get("posts/{$post->id}/comments/{$comment2->id}")->assertNotFound(); } } ================================================ FILE: tests/Support/Account.php ================================================ hasMany(Post::class); } } ================================================ FILE: tests/Support/Comment.php ================================================ belongsTo(Post::class); } } ================================================ FILE: tests/Support/CommentsController.php ================================================ increments('id'); $table->timestamps(); }); Schema::create('posts', function (Blueprint $table) { $table->increments('id'); $table->foreignId('account_id')->constrained(); $table->timestamps(); }); Schema::create('comments', function (Blueprint $table) { $table->increments('id'); $table->foreignId('post_id')->constrained(); $table->timestamps(); }); } public function down() { Schema::dropIfExists('accounts'); Schema::dropIfExists('posts'); Schema::dropIfExists('comments'); } } ================================================ FILE: tests/Support/Post.php ================================================ belongsTo(Account::class); } public function comments() { return $this->hasMany(Comment::class); } } ================================================ FILE: tests/Support/PostsController.php ================================================ setUpDatabase($this->app); } protected function getPackageProviders($app) { return [ KeyableServiceProvider::class, ]; } protected function getEnvironmentSetUp($app) { // Setup default database to use sqlite :memory: $app['config']->set('database.default', 'testbench'); $app['config']->set('database.connections.testbench', [ 'driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '', ]); } protected function setUpDatabase($app) { $app['db']->connection()->getSchemaBuilder()->create('test_models', function (Blueprint $table) { $table->increments('id'); $table->timestamps(); }); $this->prepareDatabaseForHasCustomFieldsModel(); $this->runMigrationStub(); } protected function runMigrationStub() { include_once __DIR__ . '/../database/migrations/2019_04_09_225232_create_api_keys_table.php'; (new \CreateApiKeysTable())->up(); } protected function prepareDatabaseForHasCustomFieldsModel() { include_once __DIR__ . '/../tests/Support/Migrations/create_test_tables.php'; (new \CreateTestTables())->up(); } protected function resetDatabase() { $this->artisan('migrate:fresh'); $this->runMigrationStub(); } } ================================================ FILE: tests/Unit/Console/Commands/DeleteApiKey.php ================================================ apiKeys()->create(); $this->assertNotSoftDeleted($apiKey); $this->artisan('api-key:delete', [ '--id' => $apiKey->getKey() ]); $this->assertSoftDeleted($apiKey); } } ================================================ FILE: tests/Unit/Console/Commands/GenerateApiKey.php ================================================ assertDatabaseEmpty('api_keys'); // Act $this->withoutMockingConsoleOutput() ->artisan('api-key:generate', [ '--id' => $account->getKey(), '--type' => Account::class, '--name' => 'my api key', ]); // Assert $output = Artisan::output(); $generatedKey = explode('|', $output, 2)[1]; $generatedKey = str_replace("\n", '', $generatedKey); $this->assertDatabaseHas('api_keys', [ 'key' => hash('sha256', $generatedKey), 'keyable_id' => $account->getKey(), 'keyable_type' => Account::class, 'name' => 'my api key', ]); } } ================================================ FILE: tests/Unit/Console/Commands/HashApiKeys.php ================================================ $account->getKey(), 'keyable_type' => Account::class, 'key' => $plainTextApiKey1, ]); }); $plainTextApiKey2 = ApiKey::generate(); $apiKeyNotHashed2 = Model::withoutEvents(function () use ($plainTextApiKey2, $account) { return ApiKey::create([ 'keyable_id' => $account->getKey(), 'keyable_type' => Account::class, 'key' => $plainTextApiKey2, ]); }); $this->assertDatabaseCount('api_keys', 2); $this->assertEquals($plainTextApiKey1, $apiKeyNotHashed1->key); $this->assertEquals($plainTextApiKey2, $apiKeyNotHashed2->key); $this->assertDatabaseHas('api_keys', [ 'id' => $apiKeyNotHashed1->id, 'key' => $plainTextApiKey1, ]); $this->assertDatabaseHas('api_keys', [ 'id' => $apiKeyNotHashed2->id, 'key' => $plainTextApiKey2, ]); // Act $this->artisan('api-key:hash'); // Assert $this->assertDatabaseCount('api_keys', 2); $this->assertDatabaseHas('api_keys', [ 'id' => $apiKeyNotHashed1->id, 'key' => hash('sha256', $plainTextApiKey1), ]); $this->assertDatabaseHas('api_keys', [ 'id' => $apiKeyNotHashed2->id, 'key' => hash('sha256', $plainTextApiKey2), ]); } /** @test */ public function hash_one_api_key_at_a_time(): void { // Arrange $account = Account::create(); $plainTextApiKey1 = ApiKey::generate(); $apiKeyNotHashed1 = Model::withoutEvents(function () use ($plainTextApiKey1, $account) { return ApiKey::create([ 'keyable_id' => $account->getKey(), 'keyable_type' => Account::class, 'key' => $plainTextApiKey1, ]); }); $plainTextApiKey2 = ApiKey::generate(); $apiKeyNotHashed2 = Model::withoutEvents(function () use ($plainTextApiKey2, $account) { return ApiKey::create([ 'keyable_id' => $account->getKey(), 'keyable_type' => Account::class, 'key' => $plainTextApiKey2, ]); }); $this->assertDatabaseCount('api_keys', 2); $this->assertEquals($plainTextApiKey1, $apiKeyNotHashed1->key); $this->assertEquals($plainTextApiKey2, $apiKeyNotHashed2->key); $this->assertDatabaseHas('api_keys', [ 'id' => $apiKeyNotHashed1->id, 'key' => $plainTextApiKey1, ]); $this->assertDatabaseHas('api_keys', [ 'id' => $apiKeyNotHashed2->id, 'key' => $plainTextApiKey2, ]); // Act $this->artisan('api-key:hash', [ '--id' => $apiKeyNotHashed1->id, ]); // Assert $this->assertDatabaseCount('api_keys', 2); $this->assertDatabaseHas('api_keys', [ 'id' => $apiKeyNotHashed1->id, 'key' => hash('sha256', $plainTextApiKey1), ]); $this->assertDatabaseHas('api_keys', [ 'id' => $apiKeyNotHashed2->id, 'key' => $plainTextApiKey2, ]); } /** @test */ public function api_key_is_not_hashed_more_than_once(): void { // Arrange $account = Account::create(); $plainTextApiKey = ApiKey::generate(); $apiKey = Model::withoutEvents(function () use ($plainTextApiKey, $account) { return ApiKey::create([ 'keyable_id' => $account->getKey(), 'keyable_type' => Account::class, 'key' => $plainTextApiKey, ]); }); $this->assertDatabaseHas('api_keys', [ 'id' => $apiKey->id, 'key' => $plainTextApiKey, ]); // Act 1 $this->artisan('api-key:hash', [ '--id' => $apiKey->id, ]); // Assert 1 $this->assertDatabaseHas('api_keys', [ 'id' => $apiKey->id, 'key' => hash('sha256', $plainTextApiKey), ]); // Act 2 $this->artisan('api-key:hash', [ '--id' => $apiKey->id, ]); // Assert 2 $this->assertDatabaseHas('api_keys', [ 'id' => $apiKey->id, 'key' => hash('sha256', $plainTextApiKey), ]); } } ================================================ FILE: tests/Unit/Models/ApiKeyTest.php ================================================ $account->getKey(), 'keyable_type' => Account::class, 'name' => 'my api key', ]); $this->assertDatabaseHas('api_keys', [ 'key' => hash('sha256', $apiKey->plainTextApiKey), 'keyable_id' => $account->getKey(), 'keyable_type' => Account::class, 'name' => 'my api key', ]); } }