├── .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 ├── 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 /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | composer.phar 3 | composer.lock 4 | .DS_Store 5 | .php_cs.cache -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) Givebutter, Inc. https://givebutter.com 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Keyable 2 | 3 | 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. 4 | 5 | [![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) 6 | 7 | ## Installation 8 | 9 | Require the ```givebutter/laravel-keyable``` package in your ```composer.json``` and update your dependencies: 10 | 11 | ```bash 12 | composer require givebutter/laravel-keyable 13 | ``` 14 | 15 | Publish the migration and config files: 16 | ```bash 17 | php artisan vendor:publish --provider="Givebutter\LaravelKeyable\KeyableServiceProvider" 18 | ``` 19 | 20 | Run the migration: 21 | ```bash 22 | php artisan migrate 23 | ``` 24 | 25 | ## Usage 26 | 27 | Add the ```Givebutter\LaravelKeyable\Keyable``` trait to your model(s): 28 | 29 | ```php 30 | use Illuminate\Database\Eloquent\Model; 31 | use Givebutter\LaravelKeyable\Keyable; 32 | 33 | class Account extends Model 34 | { 35 | use Keyable; 36 | 37 | // ... 38 | } 39 | ``` 40 | 41 | Add the ```auth.apiKey``` middleware to the ```mapApiRoutes()``` function in your ```App\Providers\RouteServiceProvider``` file: 42 | 43 | ```php 44 | // ... 45 | 46 | protected function mapApiRoutes() 47 | { 48 | Route::prefix('api') 49 | ->middleware(['api', 'auth.apikey']) 50 | ->namespace($this->namespace . '\API') 51 | ->group(base_path('routes/api.php')); 52 | } 53 | 54 | // ... 55 | ``` 56 | 57 | The middleware will authenticate API requests, ensuring they contain an API key that is valid. 58 | 59 | ### Generating API keys 60 | 61 | You can generate new API keys by calling the `createApiKey()` method from the `Keyable` trait. 62 | 63 | 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. 64 | 65 | ```php 66 | $newApiKey = $keyable->createApiKey(); 67 | 68 | $newApiKey->plainTextApiKey // This is the key you should use to authenticate requests 69 | $newApiKey->apiKey // The instance of ApiKey just created 70 | ``` 71 | 72 | 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. 73 | 74 | ```php 75 | $myApiKey = ApiKey::create([ 76 | 'keyable_id' => $account->getKey(), 77 | 'keyable_type' => Account::class, 78 | 'name' => 'My api key', 79 | ]); 80 | 81 | $myApiKey->plainTextApikey // Token to be used to authenticate requests 82 | ``` 83 | 84 | Keep in mind `plainTextApikey` will only be populated immediately after creating the key. 85 | 86 | ### Accessing keyable models in your controllers 87 | The model associated with the key will be attached to the incoming request as ```keyable```: 88 | 89 | ```php 90 | use App\Http\Controllers\Controller; 91 | 92 | class FooController extends Controller { 93 | 94 | public function index(Request $request) 95 | { 96 | $model = $request->keyable; 97 | 98 | // ... 99 | } 100 | 101 | } 102 | ``` 103 | Now you can use the keyable model to scope your associated API resources, for example: 104 | ```php 105 | return $model->foo()->get(); 106 | ``` 107 | 108 | ### Keys Without Models 109 | 110 | 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: 111 | 112 | ```php 113 | true 118 | 119 | ]; 120 | ``` 121 | 122 | ## Making Requests 123 | 124 | By default, laravel-keyable uses bearer tokens to authenticate requests. Attach the API key to the header of each request: 125 | 126 | ``` 127 | Authorization: Bearer 128 | ``` 129 | 130 | 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`. 131 | ```php 132 | 'header', 137 | 138 | 'key' => 'X-Authorization', 139 | 140 | ]; 141 | ``` 142 | 143 | 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: 144 | ```php 145 | 'parameter', 150 | 151 | 'key' => 'api_key' 152 | 153 | ]; 154 | ``` 155 | Now you can make requests like this: 156 | ```php 157 | https://example.com/api/posts?api_key= 158 | ``` 159 | 160 | ## Authorizing Requests 161 | 162 | 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. 163 | 164 | To begin, add the `AuthorizesKeyableRequests` trait to your base `Controller.php` class: 165 | 166 | ```php 167 | posts()->find($post->id)); 196 | } 197 | 198 | } 199 | ``` 200 | 201 | Lastly, register your policies in `AuthServiceProvider.php`: 202 | 203 | ```php 204 | PostPolicy::class 221 | ]; 222 | 223 | public function boot(GateContract $gate) 224 | { 225 | // ... 226 | Keyable::registerKeyablePolicies($this->keyablePolicies); 227 | } 228 | 229 | } 230 | ``` 231 | 232 | In your controller, you can now authorize the request using the policy by calling `$this->authorizeKeyable(, )`: 233 | 234 | ```php 235 | authorizeKeyable('view', $post); 247 | // ... 248 | } 249 | 250 | } 251 | ``` 252 | 253 | ## Keyable Model Scoping 254 | 255 | 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: 256 | 257 | ```php 258 | use App\Models\Post; 259 | 260 | Route::get('/posts/{post}', function (Post $post) { 261 | return $post; 262 | }); 263 | ``` 264 | 265 | You may instruct the package to apply the scope by invoking the `keyableScoped` method when defining your route: 266 | 267 | ```php 268 | use App\Models\Post; 269 | 270 | Route::get('/posts/{post}', function (Post $post) { 271 | return $post; 272 | })->keyableScoped(); 273 | ``` 274 | 275 | 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. 276 | 277 | 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: 278 | 279 | ```php 280 | use App\Models\Post; 281 | use App\Models\User; 282 | 283 | Route::get('/users/{user}/posts/{post}', function (User $user, Post $post) { 284 | return $post; 285 | })->scopeBindings()->keyableScoped(); 286 | ``` 287 | 288 | ## Artisan Commands 289 | 290 | Generate an API key: 291 | 292 | ```bash 293 | php artisan api-key:generate --id=1 --type="App\Models\Account" --name="My api key" 294 | ``` 295 | 296 | Delete an API key: 297 | ```bash 298 | php artisan api-key:delete --id=12345 299 | ``` 300 | 301 | ## Upgrading 302 | 303 | Please see [UPGRADING](UPGRADING.md) for details. 304 | 305 | ## Security 306 | 307 | If you discover any security related issues, please email [liran@givebutter.com](mailto:liran@givebutter.com). 308 | 309 | ## License 310 | Released under the [MIT](https://choosealicense.com/licenses/mit/) license. See [LICENSE](LICENSE.md) for more information. 311 | -------------------------------------------------------------------------------- /UPGRADING.md: -------------------------------------------------------------------------------- 1 | ## Upgrade guide 2 | 3 | ### From 2.1.1 to 3.0.0 4 | 5 | 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. 6 | 7 | #### Step 1: `api_keys` table updates 8 | 9 | Implement the following changes on your `api_keys` table. 10 | 11 | - Add a new nullable string column called `name`. 12 | - Modify the existing `key` column to increase its length from 40 to 64. 13 | 14 | #### Step 2: Update the package to version 3.0.0 15 | 16 | ```bash 17 | composer require givebutter/laravel-keyable:3.0.0 18 | ``` 19 | 20 | #### Step 3. Turn on `compatibility_mode` 21 | 22 | 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. 23 | 24 | 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. 25 | 26 | It is specially useful if you have a very large `api_keys` table, which could take a while to hash all existing API keys. 27 | 28 | It points to an environment variable called `KEYABLE_COMPATIBILITY_MODE`, but you can update it to whatever you need of course. 29 | 30 | Make sure to update `KEYABLE_COMPATIBILITY_MODE` to `true` if you want to make use of that feature. 31 | 32 | #### Step 4. Hash existing API keys 33 | 34 | 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. 35 | 36 | ```bash 37 | php artisan api-key:hash 38 | ``` 39 | 40 | It is also possible to hash a single API key at a time, by passing an `--id` option. 41 | 42 | ```bash 43 | php artisan api-key:hash --id=API_KEY_ID 44 | ``` 45 | 46 | Be very careful with this option, as each API key should be hashed only once. 47 | 48 | Ideally you should only use it for testing and on your own API keys. 49 | 50 | 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. 51 | 52 | #### Step 5. Turn off compatibility mode 53 | 54 | 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. 55 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "givebutter/laravel-keyable", 3 | "description": "Add API keys to your Laravel models", 4 | "license": "MIT", 5 | "keywords": [ 6 | "laravel", 7 | "php", 8 | "api", 9 | "rest", 10 | "json", 11 | "api keys", 12 | "api authentication" 13 | ], 14 | "homepage": "https://github.com/givebutter/laravel-keyable", 15 | "authors": [ 16 | { 17 | "name": "Liran Cohen", 18 | "email": "liran@givebutter.com" 19 | } 20 | ], 21 | "minimum-stability": "dev", 22 | "prefer-stable": true, 23 | "require": { 24 | "php": "^7.0|^8.0" 25 | }, 26 | "autoload": { 27 | "psr-4": { 28 | "Givebutter\\LaravelKeyable\\": "src/" 29 | } 30 | }, 31 | "autoload-dev": { 32 | "psr-4": { 33 | "Givebutter\\Tests\\": "tests/" 34 | } 35 | }, 36 | "extra": { 37 | "laravel": { 38 | "providers": [ 39 | "Givebutter\\LaravelKeyable\\KeyableServiceProvider" 40 | ] 41 | } 42 | }, 43 | "require-dev": { 44 | "phpunit/phpunit": "^9.5", 45 | "orchestra/testbench": "^8.0" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /config/keyable.php: -------------------------------------------------------------------------------- 1 | 'bearer', 17 | 18 | 'key' => null, 19 | 20 | /* 21 | |-------------------------------------------------------------------------- 22 | | Empty Models 23 | |-------------------------------------------------------------------------- 24 | | 25 | | Set this to true to allow API keys without an associated model. 26 | | 27 | */ 28 | 29 | 'allow_empty_models' => false, 30 | 31 | /* 32 | |-------------------------------------------------------------------------- 33 | | Compatibility mode 34 | |-------------------------------------------------------------------------- 35 | | 36 | | Set this to true to instruct this package to accept both hashed and non 37 | | hashed API keys. 38 | | 39 | | This is useful to keep your app running smoothly while you are going 40 | | throught the upgrade steps for version 2.1.1 to 3.0.0, especially if you 41 | | have a very large api_keys table, which can take a while to hash all 42 | | existing API keys. 43 | | 44 | | Once the new database changes are in place and all existing keys are 45 | | hashed, you should set this flag to false to instruct this package to 46 | | only look for hashed API keys. 47 | | 48 | */ 49 | 50 | 'compatibility_mode' => env('KEYABLE_COMPATIBILITY_MODE', false), 51 | 52 | ]; 53 | -------------------------------------------------------------------------------- /database/migrations/2019_04_09_225232_create_api_keys_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 18 | $table->nullableMorphs('keyable'); 19 | $table->string('name')->nullable(); 20 | $table->string('key', 64); 21 | $table->dateTime('last_used_at')->nullable(); 22 | $table->timestamps(); 23 | $table->softDeletes(); 24 | $table->index('key'); 25 | }); 26 | } 27 | 28 | /** 29 | * Reverse the migrations. 30 | * 31 | * @return void 32 | */ 33 | public function down() 34 | { 35 | Schema::dropIfExists('api_keys'); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Auth/AuthorizesKeyableRequests.php: -------------------------------------------------------------------------------- 1 | apiKey; 19 | $keyable = request()->keyable; 20 | 21 | if ($policy = $this->getKeyablePolicy($object)) { 22 | $policyClass = (new $policy()); 23 | 24 | if (method_exists($policyClass, 'before')) { 25 | $before = $policyClass->before($apiKey, $keyable, $object); 26 | if (! is_null($before) && $before) { 27 | return new Response(''); 28 | } 29 | } 30 | 31 | if ($policyClass->$ability($apiKey, $keyable, $object)) { 32 | return new Response(''); 33 | } 34 | } 35 | 36 | //Throw exception 37 | throw new AuthorizationException('This action is unauthorized.'); 38 | } 39 | 40 | /** 41 | * Get the associated policy. 42 | * 43 | * @return policy 44 | */ 45 | public function getKeyablePolicy($object) 46 | { 47 | return Keyable::getKeyablePolicies()[get_class($object)] ?? null; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Auth/Keyable.php: -------------------------------------------------------------------------------- 1 | policies = $policies; 12 | } 13 | 14 | public function getKeyablePolicies() 15 | { 16 | return $this->policies; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Console/Commands/DeleteApiKey.php: -------------------------------------------------------------------------------- 1 | option('id')); 40 | 41 | $key->delete(); 42 | 43 | $this->info('API key successfully deleted.'); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Console/Commands/GenerateApiKey.php: -------------------------------------------------------------------------------- 1 | create([ 43 | 'keyable_id' => $this->option('id'), 44 | 'keyable_type' => $this->option('type'), 45 | 'name' => $this->option('name'), 46 | ]); 47 | 48 | $this->info('The following API key was created: ' . "{$apiKey->getKey()}|{$apiKey->plainTextApiKey}"); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Console/Commands/HashApiKeys.php: -------------------------------------------------------------------------------- 1 | withTrashed() 36 | ->when($this->option('id'), function (Builder $query, int $id) { 37 | $query->where('id', $id); 38 | }) 39 | ->whereRaw('LENGTH(api_keys.key) != 64') 40 | ->eachById(function (ApiKey $apiKey) { 41 | $apiKey->update([ 42 | 'key' => hash('sha256', $apiKey->key), 43 | ]); 44 | 45 | $this->info("API key #{$apiKey->getKey()} successfully hashed."); 46 | }, 250); 47 | 48 | $this->info('All API keys were successfully hashed.'); 49 | }); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Facades/Keyable.php: -------------------------------------------------------------------------------- 1 | missing($param)) { 26 | continue; 27 | } 28 | 29 | $message = "Request param '{$param}' is not allowed."; 30 | 31 | if ($request->wantsJson()) { 32 | return response()->json(['message' => $message], 400); 33 | } 34 | 35 | return response($message, 400); 36 | } 37 | 38 | //Get API token from request 39 | $token = $this->getKeyFromRequest($request); 40 | 41 | //Check for presence of key 42 | if (! $token) { 43 | return $this->unauthorizedResponse(); 44 | } 45 | 46 | //Get API key 47 | $apiKey = ApiKey::getByKey($token); 48 | 49 | //Validate key 50 | if (! ($apiKey instanceof ApiKey)) { 51 | return $this->unauthorizedResponse(); 52 | } 53 | 54 | //Get the model 55 | $keyable = $apiKey->keyable; 56 | 57 | //Validate model 58 | if (config('keyable.allow_empty_models', false)) { 59 | if (! $keyable && (! is_null($apiKey->keyable_type) || ! is_null($apiKey->keyable_id))) { 60 | return $this->unauthorizedResponse(); 61 | } 62 | } else { 63 | if (! $keyable) { 64 | return $this->unauthorizedResponse(); 65 | } 66 | } 67 | 68 | //Attach the apikey object to the request 69 | $request->merge(['apiKey' => $apiKey]); 70 | if ($keyable) { 71 | $request->merge(['keyable' => $keyable]); 72 | } 73 | 74 | //Update last_used_at 75 | $apiKey->markAsUsed(); 76 | 77 | //Return 78 | return $next($request); 79 | } 80 | 81 | protected function getKeyFromRequest($request) 82 | { 83 | $mode = config('keyable.mode', 'bearer'); 84 | 85 | switch ($mode) { 86 | case 'bearer': 87 | return $request->bearerToken(); 88 | break; 89 | case 'header': 90 | return $request->header(config('keyable.key', 'X-Authorization')); 91 | break; 92 | case 'parameter': 93 | return $request->input(config('keyable.key', 'api_key')); 94 | break; 95 | } 96 | } 97 | 98 | protected function unauthorizedResponse() 99 | { 100 | return response([ 101 | 'error' => [ 102 | 'message' => 'Unauthorized', 103 | ], 104 | ], 401); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/Http/Middleware/EnforceKeyableScope.php: -------------------------------------------------------------------------------- 1 | route(); 25 | 26 | if (empty($route->parameterNames())) { 27 | return $next($request); 28 | } 29 | 30 | $parameterName = $route->parameterNames()[0]; 31 | $parameterValue = $route->originalParameters()[$parameterName]; 32 | $parameter = Arr::first($route->signatureParameters(UrlRoutable::class)); 33 | $instance = app(Reflector::getParameterClassName($parameter)); 34 | 35 | $childRouteBindingMethod = $route->allowsTrashedBindings() 36 | ? 'resolveSoftDeletableChildRouteBinding' 37 | : 'resolveChildRouteBinding'; 38 | 39 | if (! $request->keyable->{$childRouteBindingMethod}( 40 | $parameterName, 41 | $parameterValue, 42 | $route->bindingFieldFor($parameterName) 43 | )) { 44 | throw (new ModelNotFoundException)->setModel(get_class($instance), [$parameterValue]); 45 | } 46 | 47 | return $next($request); 48 | } 49 | 50 | protected static function getParameterName($name, $parameters) 51 | { 52 | if (array_key_exists($name, $parameters)) { 53 | return $name; 54 | } 55 | 56 | if (array_key_exists($snakedName = Str::snake($name), $parameters)) { 57 | return $snakedName; 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Keyable.php: -------------------------------------------------------------------------------- 1 | morphMany(ApiKey::class, 'keyable'); 13 | } 14 | 15 | public function createApiKey(array $attributes = []): NewApiKey 16 | { 17 | $planTextApiKey = ApiKey::generate(); 18 | 19 | $apiKey = Model::withoutEvents(function () use ($planTextApiKey, $attributes) { 20 | return $this->apiKeys()->create([ 21 | 'key' => hash('sha256', $planTextApiKey), 22 | 'name' => $attributes['name'] ?? null, 23 | ]); 24 | }); 25 | 26 | return new NewApiKey($apiKey, "{$apiKey->getKey()}|{$planTextApiKey}"); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/KeyableServiceProvider.php: -------------------------------------------------------------------------------- 1 | publishes([ 25 | __DIR__ . '/../config/keyable.php' => config_path('keyable.php'), 26 | ]); 27 | 28 | $this->loadMigrationsFrom(__DIR__ . '/../database/migrations'); 29 | 30 | $this->registerMiddleware($router); 31 | 32 | $this->registerCommands(); 33 | 34 | $this->registerMacros(); 35 | } 36 | 37 | /** 38 | * Register services. 39 | * 40 | * @return void 41 | */ 42 | public function register() 43 | { 44 | // 45 | } 46 | 47 | protected function registerCommands() 48 | { 49 | if ($this->app->runningInConsole()) { 50 | $this->commands([ 51 | GenerateApiKey::class, 52 | DeleteApiKey::class, 53 | HashApiKeys::class, 54 | ]); 55 | } 56 | } 57 | 58 | /** 59 | * Register middleware. 60 | * 61 | * Support added for different Laravel versions 62 | * 63 | * @param Router $router 64 | */ 65 | protected function registerMiddleware(Router $router) 66 | { 67 | $versionComparison = version_compare(app()->version(), '5.4.0'); 68 | if ($versionComparison >= 0) { 69 | $router->aliasMiddleware('auth.apikey', AuthenticateApiKey::class); 70 | $router->aliasMiddleware('keyableScoped', EnforceKeyableScope::class); 71 | } else { 72 | $router->middleware('auth.apikey', AuthenticateApiKey::class); 73 | $router->middleware('keyableScoped', EnforceKeyableScope::class); 74 | } 75 | } 76 | 77 | protected function registerMacros() 78 | { 79 | PendingResourceRegistration::macro('keyableScoped', function () { 80 | $this->middleware('keyableScoped'); 81 | 82 | return $this; 83 | }); 84 | 85 | Route::macro('keyableScoped', function () { 86 | $this->middleware('keyableScoped'); 87 | 88 | return $this; 89 | }); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Models/ApiKey.php: -------------------------------------------------------------------------------- 1 | 'datetime', 28 | ]; 29 | 30 | public static function boot() 31 | { 32 | parent::boot(); 33 | 34 | static::creating(function (ApiKey $apiKey) { 35 | if (is_null($apiKey->key)) { 36 | $apiKey->plainTextApiKey = self::generate(); 37 | $apiKey->key = hash('sha256', $apiKey->plainTextApiKey); 38 | } 39 | }); 40 | } 41 | 42 | /** 43 | * @return \Illuminate\Database\Eloquent\Relations\MorphTo 44 | */ 45 | public function keyable() 46 | { 47 | return $this->morphTo(); 48 | } 49 | 50 | /** 51 | * Generate a secure unique API key. 52 | * 53 | * @return string 54 | */ 55 | public static function generate() 56 | { 57 | do { 58 | $key = Str::random(40); 59 | } while (self::keyExists($key)); 60 | 61 | return $key; 62 | } 63 | 64 | /** 65 | * Get ApiKey record by key value. 66 | * 67 | * @param string $key 68 | * 69 | * @return bool 70 | */ 71 | public static function getByKey($key) 72 | { 73 | return self::ofKey($key)->first(); 74 | } 75 | 76 | /** 77 | * Check if a key already exists. 78 | * 79 | * Includes soft deleted records 80 | * 81 | * @param string $key 82 | * 83 | * @return bool 84 | */ 85 | public static function keyExists($key) 86 | { 87 | return self::ofKey($key) 88 | ->withTrashed() 89 | ->first() instanceof self; 90 | } 91 | 92 | /** 93 | * Mark key as used. 94 | */ 95 | public function markAsUsed() 96 | { 97 | return $this->forceFill([ 98 | 'last_used_at' => $this->freshTimestamp() 99 | ])->save(); 100 | } 101 | 102 | public function scopeOfKey(Builder $query, string $key): Builder 103 | { 104 | $compatibilityMode = config('keyable.compatibility_mode', false); 105 | 106 | if ($compatibilityMode) { 107 | return $query->where(function (Builder $query) use ($key) { 108 | if (! str_contains($key, '|')) { 109 | return $query->where('key', $key) 110 | ->orWhere('key', hash('sha256', $key)); 111 | } 112 | 113 | [$id, $key] = explode('|', $key, 2); 114 | 115 | return $query 116 | ->where(function (Builder $query) use ($key, $id) { 117 | return $query->where('key', $key) 118 | ->where('id', $id); 119 | }) 120 | ->orWhere(function (Builder $query) use ($key, $id) { 121 | return $query->where('key', hash('sha256', $key)) 122 | ->where('id', $id); 123 | }); 124 | }); 125 | } 126 | 127 | if (! str_contains($key, '|')) { 128 | return $query->where('key', hash('sha256', $key)); 129 | } 130 | 131 | [$id, $key] = explode('|', $key, 2); 132 | 133 | return $query->where('id', $id) 134 | ->where('key', hash('sha256', $key)); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/NewApiKey.php: -------------------------------------------------------------------------------- 1 | middleware(['api', 'auth.apikey']); 18 | 19 | $account = Account::create(); 20 | 21 | $this->withHeaders([ 22 | 'Authorization' => 'Bearer ' . $account->createApiKey()->plainTextApiKey, 23 | ])->get("/api/posts")->assertOk(); 24 | } 25 | 26 | /** @test */ 27 | public function request_with_valid_api_key_without_id_prefix_responds_ok() 28 | { 29 | Route::get("/api/posts", function () { 30 | return response('All good', 200); 31 | })->middleware(['api', 'auth.apikey']); 32 | 33 | $account = Account::create(); 34 | $plainTextApiKey = $account->createApiKey()->plainTextApiKey; 35 | [$id, $apiKeyWithoutIdPrefix] = explode('|', $plainTextApiKey); 36 | 37 | $this->assertEquals("{$id}|{$apiKeyWithoutIdPrefix}", $plainTextApiKey); 38 | 39 | $this->withHeaders([ 40 | 'Authorization' => 'Bearer ' . $apiKeyWithoutIdPrefix, 41 | ])->get("/api/posts")->assertOk(); 42 | } 43 | 44 | /** @test */ 45 | public function request_having_api_key_with_valid_but_mismatched_id_and_key_responds_unauthorized() 46 | { 47 | Route::get("/api/posts", function () { 48 | return response('All good', 200); 49 | })->middleware(['api', 'auth.apikey']); 50 | 51 | $account = Account::create(); 52 | $apiKey1 = $account->createApiKey(); 53 | $apiKey2 = $account->createApiKey(); 54 | 55 | $this->assertDatabaseHas('api_keys', [ 56 | 'id' => $apiKey1->apiKey->id, 57 | ]); 58 | 59 | $this->assertDatabaseHas('api_keys', [ 60 | 'id' => $apiKey2->apiKey->id, 61 | ]); 62 | 63 | $idFromApiKey1 = explode('|', $apiKey1->plainTextApiKey)[0]; 64 | $keyFromApiKey2 = explode('|', $apiKey2->plainTextApiKey)[1]; 65 | 66 | $mismatchedApiKey = "{$idFromApiKey1}|{$keyFromApiKey2}"; 67 | 68 | $this->assertNotEquals($mismatchedApiKey, $apiKey1->plainTextApiKey); 69 | $this->assertNotEquals($mismatchedApiKey, $apiKey2->plainTextApiKey); 70 | 71 | $this->withHeaders([ 72 | 'Authorization' => 'Bearer ' . $mismatchedApiKey, 73 | ])->get("/api/posts")->assertUnauthorized(); 74 | } 75 | 76 | /** @test */ 77 | public function request_without_api_key_responds_unauthorized() 78 | { 79 | Route::get("/api/posts", function () { 80 | return response('All good', 200); 81 | })->middleware(['api', 'auth.apikey']); 82 | 83 | $this->get("/api/posts")->assertUnauthorized(); 84 | } 85 | 86 | /** 87 | * @test 88 | * @dataProvider forbiddenRequestParams 89 | */ 90 | public function throw_exception_if_unauthorized_get_request_has_forbidden_request_query_params(string $queryParam): void 91 | { 92 | Route::get('/api/posts', function () { 93 | return response('All good', 200); 94 | })->middleware(['api', 'auth.apikey']); 95 | 96 | $this->get("/api/posts?{$queryParam}=value") 97 | ->assertBadRequest() 98 | ->assertContent("Request param '{$queryParam}' is not allowed."); 99 | } 100 | 101 | /** 102 | * @test 103 | * @dataProvider forbiddenRequestParams 104 | */ 105 | public function throw_exception_if_unauthorized_post_request_has_forbidden_request_body_params(string $bodyParam): void 106 | { 107 | Route::post('/api/posts', function () { 108 | return response('All good', 200); 109 | })->middleware(['api', 'auth.apikey']); 110 | 111 | $this->post('/api/posts', [$bodyParam => 'value']) 112 | ->assertBadRequest() 113 | ->assertContent("Request param '{$bodyParam}' is not allowed."); 114 | } 115 | 116 | /** 117 | * @test 118 | * @dataProvider forbiddenRequestParams 119 | */ 120 | public function throw_exception_if_unauthorized_json_get_request_has_forbidden_request_query_params(string $queryParam): void 121 | { 122 | Route::get('/api/posts', function () { 123 | return response('All good', 200); 124 | })->middleware(['api', 'auth.apikey']); 125 | 126 | $this->getJson("/api/posts?{$queryParam}=value") 127 | ->assertBadRequest() 128 | ->assertJson(['message' => "Request param '{$queryParam}' is not allowed."]); 129 | } 130 | 131 | /** 132 | * @test 133 | * @dataProvider forbiddenRequestParams 134 | */ 135 | public function throw_exception_if_unauthorized_json_post_request_has_forbidden_request_body_params(string $bodyParam): void 136 | { 137 | Route::post('/api/posts', function () { 138 | return response('All good', 200); 139 | })->middleware(['api', 'auth.apikey']); 140 | 141 | $this->postJson('/api/posts', [$bodyParam => 'value']) 142 | ->assertBadRequest() 143 | ->assertJson(['message' => "Request param '{$bodyParam}' is not allowed."]); 144 | } 145 | 146 | public function forbiddenRequestParams(): array 147 | { 148 | return [ 149 | ['keyable'], 150 | ['apiKey'], 151 | ]; 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /tests/Feature/CompatibilityMode.php: -------------------------------------------------------------------------------- 1 | middleware(['api', 'auth.apikey'])->keyableScoped(); 22 | 23 | $account = Account::create(); 24 | $post = $account->posts()->create(); 25 | 26 | // Store the first api key as non hashed 27 | $plainTextApiKey1 = ApiKey::generate(); 28 | $apiKey1 = Model::withoutEvents(function () use ($plainTextApiKey1, $account) { 29 | return ApiKey::create([ 30 | 'keyable_id' => $account->getKey(), 31 | 'keyable_type' => Account::class, 32 | 'key' => $plainTextApiKey1, 33 | ]); 34 | }); 35 | 36 | // Store the second api key as non hashed 37 | $plainTextApiKey2 = ApiKey::generate(); 38 | $apiKey2 = Model::withoutEvents(function () use ($plainTextApiKey2, $account) { 39 | return ApiKey::create([ 40 | 'keyable_id' => $account->getKey(), 41 | 'keyable_type' => Account::class, 42 | 'key' => $plainTextApiKey2, 43 | ]); 44 | }); 45 | 46 | $this->assertDatabaseCount('api_keys', 2); 47 | $this->assertDatabaseHas('api_keys', [ 48 | 'id' => $apiKey1->getKey(), 49 | 'key' => $plainTextApiKey1, 50 | ]); 51 | $this->assertDatabaseHas('api_keys', [ 52 | 'id' => $apiKey2->getKey(), 53 | 'key' => $plainTextApiKey2, 54 | ]); 55 | 56 | // Ensure compatibility mode is on 57 | Config::set('keyable.compatibility_mode', true); 58 | 59 | // Hash only the second api key 60 | $this->artisan('api-key:hash', [ 61 | '--id' => $apiKey2->getKey(), 62 | ]); 63 | 64 | $this->assertDatabaseCount('api_keys', 2); 65 | $this->assertDatabaseHas('api_keys', [ 66 | 'id' => $apiKey1->getKey(), 67 | 'key' => $plainTextApiKey1, 68 | ]); 69 | $this->assertDatabaseHas('api_keys', [ 70 | 'id' => $apiKey2->getKey(), 71 | 'key' => $apiKey2->fresh()->key, 72 | ]); 73 | 74 | // Assert that non hashed api keys works 75 | $this->withHeaders([ 76 | 'Authorization' => "Bearer {$plainTextApiKey1}", 77 | ])->get("/api/posts/{$post->id}")->assertOk(); 78 | 79 | // Assert that non hashed api keys with ID prefix works 80 | $this->withHeaders([ 81 | 'Authorization' => "Bearer {$apiKey1->id}|{$plainTextApiKey1}", 82 | ])->get("/api/posts/{$post->id}")->assertOk(); 83 | 84 | // Assert that hashed api keys works 85 | $this->withHeaders([ 86 | 'Authorization' => "Bearer {$plainTextApiKey2}", 87 | ])->get("/api/posts/{$post->id}")->assertOk(); 88 | 89 | // Assert that hashed api keys with ID prefix works 90 | $this->withHeaders([ 91 | 'Authorization' => "Bearer {$apiKey2->id}|{$plainTextApiKey2}", 92 | ])->get("/api/posts/{$post->id}")->assertOk(); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /tests/Feature/EnforceKeyableScope.php: -------------------------------------------------------------------------------- 1 | middleware(['api', 'auth.apikey'])->keyableScoped(); 21 | 22 | $account = Account::create(); 23 | $post = $account->posts()->create(); 24 | 25 | $this->withHeaders([ 26 | 'Authorization' => 'Bearer ' . $account->createApiKey()->plainTextApiKey, 27 | ])->get("/api/posts/{$post->id}")->assertOk(); 28 | } 29 | 30 | /** @test */ 31 | public function request_with_model_not_owned_by_keyable_throws_model_not_found() 32 | { 33 | Route::get("/api/posts/{post}", function (Request $request, Post $post) { 34 | return response('All good', 200); 35 | })->middleware([ 'api', 'auth.apikey'])->keyableScoped(); 36 | 37 | $account = Account::create(); 38 | $account2 = Account::create(); 39 | $post = $account2->posts()->create(); 40 | 41 | $this->withHeaders([ 42 | 'Authorization' => 'Bearer ' . $account->createApiKey()->plainTextApiKey, 43 | ])->get("/api/posts/{$post->id}")->assertNotFound(); 44 | } 45 | 46 | /** @test */ 47 | public function works_with_resource_routes() 48 | { 49 | Route::prefix('api')->middleware(['api', 'auth.apikey'])->group(function () { 50 | Route::apiResource('posts', PostsController::class) 51 | ->only('show') 52 | ->keyableScoped(); 53 | }); 54 | 55 | /* 56 | | -------------------------------- 57 | | PASSING 58 | | -------------------------------- 59 | */ 60 | $account = Account::create(); 61 | $post = $account->posts()->create(); 62 | 63 | $this->withHeaders([ 64 | 'Authorization' => 'Bearer ' . $account->createApiKey()->plainTextApiKey, 65 | ])->get("/api/posts/{$post->id}")->assertOk(); 66 | 67 | /* 68 | | -------------------------------- 69 | | FAILING 70 | | -------------------------------- 71 | */ 72 | $account2 = Account::create(); 73 | $post = $account2->posts()->create(); 74 | 75 | $this->withHeaders([ 76 | 'Authorization' => 'Bearer ' . $account->createApiKey()->plainTextApiKey, 77 | ])->get("/api/posts/{$post->id}")->assertNotFound(); 78 | } 79 | 80 | /** @test */ 81 | public function can_use_scoped_with_keyableScoped() 82 | { 83 | Route::middleware(['api', 'auth.apikey'])->group(function () { 84 | Route::apiResource('posts.comments', CommentsController::class) 85 | ->only('show') 86 | ->scoped() 87 | ->keyableScoped(); 88 | }); 89 | 90 | /* 91 | | -------------------------------- 92 | | PASSING 93 | | -------------------------------- 94 | */ 95 | $account = Account::create(); 96 | $post = $account->posts()->create(); 97 | $comment = $post->comments()->create(); 98 | 99 | $this->withHeaders([ 100 | 'Authorization' => 'Bearer ' . $account->createApiKey()->plainTextApiKey, 101 | ])->get("posts/{$post->id}/comments/{$comment->id}")->assertOk(); 102 | 103 | /* 104 | | -------------------------------- 105 | | FAILING 106 | | -------------------------------- 107 | */ 108 | $account2 = Account::create(); 109 | $post2 = $account2->posts()->create(); 110 | $comment2 = $post2->comments()->create(); 111 | 112 | $this->withHeaders([ 113 | 'Authorization' => 'Bearer ' . $account->createApiKey()->plainTextApiKey, 114 | ])->get("posts/{$post->id}/comments/{$comment2->id}")->assertNotFound(); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /tests/Support/Account.php: -------------------------------------------------------------------------------- 1 | hasMany(Post::class); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tests/Support/Comment.php: -------------------------------------------------------------------------------- 1 | belongsTo(Post::class); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tests/Support/CommentsController.php: -------------------------------------------------------------------------------- 1 | increments('id'); 13 | $table->timestamps(); 14 | }); 15 | 16 | Schema::create('posts', function (Blueprint $table) { 17 | $table->increments('id'); 18 | $table->foreignId('account_id')->constrained(); 19 | $table->timestamps(); 20 | }); 21 | 22 | Schema::create('comments', function (Blueprint $table) { 23 | $table->increments('id'); 24 | $table->foreignId('post_id')->constrained(); 25 | $table->timestamps(); 26 | }); 27 | } 28 | 29 | public function down() 30 | { 31 | Schema::dropIfExists('accounts'); 32 | Schema::dropIfExists('posts'); 33 | Schema::dropIfExists('comments'); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tests/Support/Post.php: -------------------------------------------------------------------------------- 1 | belongsTo(Account::class); 14 | } 15 | 16 | public function comments() 17 | { 18 | return $this->hasMany(Comment::class); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/Support/PostsController.php: -------------------------------------------------------------------------------- 1 | setUpDatabase($this->app); 26 | } 27 | 28 | protected function getPackageProviders($app) 29 | { 30 | return [ 31 | KeyableServiceProvider::class, 32 | ]; 33 | } 34 | 35 | protected function getEnvironmentSetUp($app) 36 | { 37 | // Setup default database to use sqlite :memory: 38 | $app['config']->set('database.default', 'testbench'); 39 | $app['config']->set('database.connections.testbench', [ 40 | 'driver' => 'sqlite', 41 | 'database' => ':memory:', 42 | 'prefix' => '', 43 | ]); 44 | } 45 | 46 | protected function setUpDatabase($app) 47 | { 48 | $app['db']->connection()->getSchemaBuilder()->create('test_models', function (Blueprint $table) { 49 | $table->increments('id'); 50 | $table->timestamps(); 51 | }); 52 | 53 | $this->prepareDatabaseForHasCustomFieldsModel(); 54 | $this->runMigrationStub(); 55 | } 56 | 57 | protected function runMigrationStub() 58 | { 59 | include_once __DIR__ . '/../database/migrations/2019_04_09_225232_create_api_keys_table.php'; 60 | (new \CreateApiKeysTable())->up(); 61 | } 62 | 63 | protected function prepareDatabaseForHasCustomFieldsModel() 64 | { 65 | include_once __DIR__ . '/../tests/Support/Migrations/create_test_tables.php'; 66 | (new \CreateTestTables())->up(); 67 | } 68 | 69 | protected function resetDatabase() 70 | { 71 | $this->artisan('migrate:fresh'); 72 | $this->runMigrationStub(); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /tests/Unit/Console/Commands/DeleteApiKey.php: -------------------------------------------------------------------------------- 1 | apiKeys()->create(); 15 | 16 | $this->assertNotSoftDeleted($apiKey); 17 | 18 | $this->artisan('api-key:delete', [ 19 | '--id' => $apiKey->getKey() 20 | ]); 21 | 22 | $this->assertSoftDeleted($apiKey); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/Unit/Console/Commands/GenerateApiKey.php: -------------------------------------------------------------------------------- 1 | assertDatabaseEmpty('api_keys'); 18 | 19 | // Act 20 | $this->withoutMockingConsoleOutput() 21 | ->artisan('api-key:generate', [ 22 | '--id' => $account->getKey(), 23 | '--type' => Account::class, 24 | '--name' => 'my api key', 25 | ]); 26 | 27 | // Assert 28 | $output = Artisan::output(); 29 | $generatedKey = explode('|', $output, 2)[1]; 30 | $generatedKey = str_replace("\n", '', $generatedKey); 31 | 32 | $this->assertDatabaseHas('api_keys', [ 33 | 'key' => hash('sha256', $generatedKey), 34 | 'keyable_id' => $account->getKey(), 35 | 'keyable_type' => Account::class, 36 | 'name' => 'my api key', 37 | ]); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/Unit/Console/Commands/HashApiKeys.php: -------------------------------------------------------------------------------- 1 | $account->getKey(), 22 | 'keyable_type' => Account::class, 23 | 'key' => $plainTextApiKey1, 24 | ]); 25 | }); 26 | 27 | $plainTextApiKey2 = ApiKey::generate(); 28 | $apiKeyNotHashed2 = Model::withoutEvents(function () use ($plainTextApiKey2, $account) { 29 | return ApiKey::create([ 30 | 'keyable_id' => $account->getKey(), 31 | 'keyable_type' => Account::class, 32 | 'key' => $plainTextApiKey2, 33 | ]); 34 | }); 35 | 36 | $this->assertDatabaseCount('api_keys', 2); 37 | 38 | $this->assertEquals($plainTextApiKey1, $apiKeyNotHashed1->key); 39 | $this->assertEquals($plainTextApiKey2, $apiKeyNotHashed2->key); 40 | 41 | $this->assertDatabaseHas('api_keys', [ 42 | 'id' => $apiKeyNotHashed1->id, 43 | 'key' => $plainTextApiKey1, 44 | ]); 45 | 46 | $this->assertDatabaseHas('api_keys', [ 47 | 'id' => $apiKeyNotHashed2->id, 48 | 'key' => $plainTextApiKey2, 49 | ]); 50 | 51 | // Act 52 | $this->artisan('api-key:hash'); 53 | 54 | // Assert 55 | $this->assertDatabaseCount('api_keys', 2); 56 | 57 | $this->assertDatabaseHas('api_keys', [ 58 | 'id' => $apiKeyNotHashed1->id, 59 | 'key' => hash('sha256', $plainTextApiKey1), 60 | ]); 61 | 62 | $this->assertDatabaseHas('api_keys', [ 63 | 'id' => $apiKeyNotHashed2->id, 64 | 'key' => hash('sha256', $plainTextApiKey2), 65 | ]); 66 | } 67 | 68 | /** @test */ 69 | public function hash_one_api_key_at_a_time(): void 70 | { 71 | // Arrange 72 | $account = Account::create(); 73 | 74 | $plainTextApiKey1 = ApiKey::generate(); 75 | $apiKeyNotHashed1 = Model::withoutEvents(function () use ($plainTextApiKey1, $account) { 76 | return ApiKey::create([ 77 | 'keyable_id' => $account->getKey(), 78 | 'keyable_type' => Account::class, 79 | 'key' => $plainTextApiKey1, 80 | ]); 81 | }); 82 | 83 | $plainTextApiKey2 = ApiKey::generate(); 84 | $apiKeyNotHashed2 = Model::withoutEvents(function () use ($plainTextApiKey2, $account) { 85 | return ApiKey::create([ 86 | 'keyable_id' => $account->getKey(), 87 | 'keyable_type' => Account::class, 88 | 'key' => $plainTextApiKey2, 89 | ]); 90 | }); 91 | 92 | $this->assertDatabaseCount('api_keys', 2); 93 | 94 | $this->assertEquals($plainTextApiKey1, $apiKeyNotHashed1->key); 95 | $this->assertEquals($plainTextApiKey2, $apiKeyNotHashed2->key); 96 | 97 | $this->assertDatabaseHas('api_keys', [ 98 | 'id' => $apiKeyNotHashed1->id, 99 | 'key' => $plainTextApiKey1, 100 | ]); 101 | 102 | $this->assertDatabaseHas('api_keys', [ 103 | 'id' => $apiKeyNotHashed2->id, 104 | 'key' => $plainTextApiKey2, 105 | ]); 106 | 107 | // Act 108 | $this->artisan('api-key:hash', [ 109 | '--id' => $apiKeyNotHashed1->id, 110 | ]); 111 | 112 | // Assert 113 | $this->assertDatabaseCount('api_keys', 2); 114 | 115 | $this->assertDatabaseHas('api_keys', [ 116 | 'id' => $apiKeyNotHashed1->id, 117 | 'key' => hash('sha256', $plainTextApiKey1), 118 | ]); 119 | 120 | $this->assertDatabaseHas('api_keys', [ 121 | 'id' => $apiKeyNotHashed2->id, 122 | 'key' => $plainTextApiKey2, 123 | ]); 124 | } 125 | 126 | /** @test */ 127 | public function api_key_is_not_hashed_more_than_once(): void 128 | { 129 | // Arrange 130 | $account = Account::create(); 131 | 132 | $plainTextApiKey = ApiKey::generate(); 133 | $apiKey = Model::withoutEvents(function () use ($plainTextApiKey, $account) { 134 | return ApiKey::create([ 135 | 'keyable_id' => $account->getKey(), 136 | 'keyable_type' => Account::class, 137 | 'key' => $plainTextApiKey, 138 | ]); 139 | }); 140 | 141 | $this->assertDatabaseHas('api_keys', [ 142 | 'id' => $apiKey->id, 143 | 'key' => $plainTextApiKey, 144 | ]); 145 | 146 | // Act 1 147 | $this->artisan('api-key:hash', [ 148 | '--id' => $apiKey->id, 149 | ]); 150 | 151 | // Assert 1 152 | $this->assertDatabaseHas('api_keys', [ 153 | 'id' => $apiKey->id, 154 | 'key' => hash('sha256', $plainTextApiKey), 155 | ]); 156 | 157 | // Act 2 158 | $this->artisan('api-key:hash', [ 159 | '--id' => $apiKey->id, 160 | ]); 161 | 162 | // Assert 2 163 | $this->assertDatabaseHas('api_keys', [ 164 | 'id' => $apiKey->id, 165 | 'key' => hash('sha256', $plainTextApiKey), 166 | ]); 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /tests/Unit/Models/ApiKeyTest.php: -------------------------------------------------------------------------------- 1 | $account->getKey(), 18 | 'keyable_type' => Account::class, 19 | 'name' => 'my api key', 20 | ]); 21 | 22 | $this->assertDatabaseHas('api_keys', [ 23 | 'key' => hash('sha256', $apiKey->plainTextApiKey), 24 | 'keyable_id' => $account->getKey(), 25 | 'keyable_type' => Account::class, 26 | 'name' => 'my api key', 27 | ]); 28 | } 29 | } 30 | --------------------------------------------------------------------------------