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.
[](https://packagist.org/packages/givebutter/laravel-keyable) [](https://packagist.org/packages/givebutter/laravel-keyable) [](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
<?php
return [
'allow_empty_models' => 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 <key>
```
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
<?php
return [
'mode' => '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
<?php
return [
'mode' => 'parameter',
'key' => 'api_key'
];
```
Now you can make requests like this:
```php
https://example.com/api/posts?api_key=<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
<?php
namespace App\Http\Controllers;
// ...
use Givebutter\LaravelKeyable\Auth\AuthorizesKeyableRequests;
class Controller extends BaseController
{
use AuthorizesKeyableRequests;
}
```
Next, create the `app/Policies/KeyablePolicies` folder and create a new policy:
```php
<?php
namespace App\Policies\KeyablePolicies;
use App\Models\Post;
use Illuminate\Database\Eloquent\Model;
use Givebutter\LaravelKeyable\Models\ApiKey;
class PostPolicy {
public function view(ApiKey $apiKey, Model $keyable, Post $post) {
return !is_null($keyable->posts()->find($post->id));
}
}
```
Lastly, register your policies in `AuthServiceProvider.php`:
```php
<?php
namespace App\Providers;
// ...
use App\Models\Post;
use App\Policies\KeyablePolicies\PostPolicy;
use Givebutter\LaravelKeyable\Facades\Keyable;
class AuthServiceProvider extends ServiceProvider
{
// ...
protected $keyablePolicies = [
Post::class => 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(<ability>, <model>)`:
```php
<?php
namespace App\Http\Controllers\PostController;
use App\Models\Post;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
class PostController extends Controller {
public function show(Post $post) {
$this->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
================================================
<?php
return [
/*
|--------------------------------------------------------------------------
| Authentication Mode
|--------------------------------------------------------------------------
|
| Supported modes: header, bearer, parameter
|
| When using header or parameter, set a key value.
|
*/
'mode' => '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
================================================
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateApiKeysTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('api_keys', function (Blueprint $table) {
$table->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
================================================
<?php
namespace Givebutter\LaravelKeyable\Auth;
use Givebutter\LaravelKeyable\Facades\Keyable;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\Access\Response;
trait AuthorizesKeyableRequests
{
/**
* Authorize a request.
*
* @return Response or throw exception
*/
public function authorizeKeyable($ability, $object)
{
$apiKey = request()->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
================================================
<?php
namespace Givebutter\LaravelKeyable\Auth;
class Keyable
{
protected $policies;
public function registerKeyablePolicies($policies)
{
return $this->policies = $policies;
}
public function getKeyablePolicies()
{
return $this->policies;
}
}
================================================
FILE: src/Console/Commands/DeleteApiKey.php
================================================
<?php
namespace Givebutter\LaravelKeyable\Console\Commands;
use Givebutter\LaravelKeyable\Models\ApiKey;
use Illuminate\Console\Command;
class DeleteApiKey extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'api-key:delete {--id= : ID of the API key you want to delete.}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Delete API key';
/**
* Create a new command instance.
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$key = ApiKey::findOrFail($this->option('id'));
$key->delete();
$this->info('API key successfully deleted.');
}
}
================================================
FILE: src/Console/Commands/GenerateApiKey.php
================================================
<?php
namespace Givebutter\LaravelKeyable\Console\Commands;
use Givebutter\LaravelKeyable\Models\ApiKey;
use Illuminate\Console\Command;
class GenerateApiKey extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'api-key:generate
{--id= : ID of the model you want to bind to this API key}
{--type= : The class name of the model you want to bind to this API key}
{--name= : The name you want to give to this API key}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Generate API key';
/**
* Create a new command instance.
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$apiKey = (new ApiKey)->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
================================================
<?php
namespace Givebutter\LaravelKeyable\Console\Commands;
use Givebutter\LaravelKeyable\Models\ApiKey;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
class HashApiKeys extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'api-key:hash {--id= : ID of the API key you want to hash}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Hash existing API keys';
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
DB::transaction(function () {
ApiKey::query()
->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
================================================
<?php
namespace Givebutter\LaravelKeyable\Events;
use Givebutter\LaravelKeyable\Models\ApiKey;
class KeyableAuthenticated
{
public function __construct(public ApiKey $apiKey)
{
}
}
================================================
FILE: src/Facades/Keyable.php
================================================
<?php
namespace Givebutter\LaravelKeyable\Facades;
use Givebutter\LaravelKeyable\Auth\Keyable as KeyableAuth;
use Illuminate\Support\Facades\Facade;
class Keyable extends Facade
{
/**
* Get the registered name of the component.
*
* @return string
*/
protected static function getFacadeAccessor(): string
{
return KeyableAuth::class;
}
}
================================================
FILE: src/Http/Middleware/AuthenticateApiKey.php
================================================
<?php
namespace Givebutter\LaravelKeyable\Http\Middleware;
use Closure;
use Givebutter\LaravelKeyable\Models\ApiKey;
use Givebutter\LaravelKeyable\Events\KeyableAuthenticated;
class AuthenticateApiKey
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param Closure $next
* @param string|null $guard
*
* @return mixed
*/
public function handle($request, Closure $next, $guard = null)
{
$forbidenRequestParams = ['apiKey', 'keyable'];
// Check if request has forbidden params
foreach ($forbidenRequestParams as $param) {
if ($request->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
================================================
<?php
namespace Givebutter\LaravelKeyable\Http\Middleware;
use Closure;
use Illuminate\Support\Arr;
use Illuminate\Support\Reflector;
use Illuminate\Contracts\Routing\UrlRoutable;
use Illuminate\Database\Eloquent\ModelNotFoundException;
class EnforceKeyableScope
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param Closure $next
* @param string|null $guard
*
* @return mixed
*/
public function handle($request, Closure $next, $guard = null)
{
$route = $request->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
================================================
<?php
namespace Givebutter\LaravelKeyable;
use Givebutter\LaravelKeyable\Models\ApiKey;
use Illuminate\Database\Eloquent\Model;
trait Keyable
{
public function apiKeys()
{
return $this->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
================================================
<?php
namespace Givebutter\LaravelKeyable;
use Illuminate\Routing\Route;
use Illuminate\Routing\Router;
use Illuminate\Support\ServiceProvider;
use Illuminate\Routing\PendingResourceRegistration;
use Givebutter\LaravelKeyable\Console\Commands\DeleteApiKey;
use Givebutter\LaravelKeyable\Console\Commands\GenerateApiKey;
use Givebutter\LaravelKeyable\Console\Commands\HashApiKeys;
use Givebutter\LaravelKeyable\Http\Middleware\AuthenticateApiKey;
use Givebutter\LaravelKeyable\Http\Middleware\EnforceKeyableScope;
class KeyableServiceProvider extends ServiceProvider
{
/**
* Bootstrap any package services.
*
* @return void
*/
public function boot(Router $router)
{
$this->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
================================================
<?php
namespace Givebutter\LaravelKeyable\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
class ApiKey extends Model
{
use SoftDeletes;
public ?string $plainTextApiKey = null;
protected $table = 'api_keys';
protected $fillable = [
'key',
'keyable_id',
'keyable_type',
'name',
'last_used_at',
];
protected $casts = [
'last_used_at' => '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
================================================
<?php
namespace Givebutter\LaravelKeyable;
use Givebutter\LaravelKeyable\Models\ApiKey;
class NewApiKey
{
public function __construct(
public ApiKey $apiKey,
public string $plainTextApiKey,
) {
//
}
}
================================================
FILE: tests/Feature/AuthenticateApiKey.php
================================================
<?php
namespace Givebutter\Tests\Feature;
use Givebutter\LaravelKeyable\Exceptions\ForbidenRequestParamException;
use Givebutter\Tests\TestCase;
use Givebutter\Tests\Support\Account;
use Illuminate\Support\Facades\Route;
class AuthenticateApiKey extends TestCase
{
/** @test */
public function request_with_api_key_responds_ok()
{
Route::get("/api/posts", function () {
return response('All good', 200);
})->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
================================================
<?php
namespace Givebutter\Tests\Feature;
use Givebutter\LaravelKeyable\Models\ApiKey;
use Givebutter\Tests\Support\Account;
use Givebutter\Tests\Support\Post;
use Givebutter\Tests\TestCase;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Route;
class CompatibilityMode extends TestCase
{
/** @test */
public function accepts_both_hashed_and_non_hashed_api_keys_when_compatibility_mode_is_on()
{
Route::get("/api/posts/{post}", function (Request $request, Post $post) {
return response('All good', 200);
})->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
================================================
<?php
namespace Givebutter\Tests\Feature;
use Illuminate\Http\Request;
use Givebutter\Tests\TestCase;
use Givebutter\Tests\Support\Post;
use Givebutter\Tests\Support\Account;
use Illuminate\Support\Facades\Route;
use Givebutter\Tests\Support\PostsController;
use Givebutter\Tests\Support\CommentsController;
class EnforceKeyableScope extends TestCase
{
/** @test */
public function request_with_parameter_must_be_owned_by_keyable()
{
Route::get("/api/posts/{post}", function (Request $request, Post $post) {
return response('All good', 200);
})->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
================================================
<?php
namespace Givebutter\Tests\Support;
use Givebutter\LaravelKeyable\Keyable;
use Illuminate\Database\Eloquent\Model;
class Account extends Model
{
use Keyable;
public function posts()
{
return $this->hasMany(Post::class);
}
}
================================================
FILE: tests/Support/Comment.php
================================================
<?php
namespace Givebutter\Tests\Support;
use Givebutter\Tests\Support\Post;
use Illuminate\Database\Eloquent\Model;
class Comment extends Model
{
public function post()
{
return $this->belongsTo(Post::class);
}
}
================================================
FILE: tests/Support/CommentsController.php
================================================
<?php
namespace Givebutter\Tests\Support;
use Illuminate\Http\Request;
use Givebutter\Tests\Support\Post;
class CommentsController
{
public function show(Request $request, Post $post, Comment $comment)
{
return response('All good', 200);
}
}
================================================
FILE: tests/Support/Migrations/create_test_tables.php
================================================
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateTestTables extends Migration
{
public function up()
{
Schema::create('accounts', function (Blueprint $table) {
$table->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
================================================
<?php
namespace Givebutter\Tests\Support;
use Givebutter\Tests\Support\Account;
use Givebutter\Tests\Support\Comment;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
public function account()
{
return $this->belongsTo(Account::class);
}
public function comments()
{
return $this->hasMany(Comment::class);
}
}
================================================
FILE: tests/Support/PostsController.php
================================================
<?php
namespace Givebutter\Tests\Support;
use Illuminate\Http\Request;
use Givebutter\Tests\Support\Post;
class PostsController
{
public function show(Request $request, Post $post)
{
return response('All good', 200);
}
}
================================================
FILE: tests/TestCase.php
================================================
<?php
namespace Givebutter\Tests;
use Illuminate\Support\Str;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Eloquent\Factories\Factory;
use Givebutter\LaravelKeyable\KeyableServiceProvider;
use Orchestra\Testbench\TestCase as OrchestraTestCase;
class TestCase extends OrchestraTestCase
{
public function setUp(): void
{
parent::setUp();
Factory::guessFactoryNamesUsing(function (string $modelName) {
$namespace = 'Database\\Factories\\';
$modelName = Str::afterLast($modelName, '\\');
return $namespace.$modelName.'Factory';
});
$this->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
================================================
<?php
namespace Givebutter\Tests\Unit\Console\Commands;
use Givebutter\Tests\Support\Account;
use Givebutter\Tests\TestCase;
class DeleteApiKey extends TestCase
{
/** @test */
public function delete_api_key(): void
{
$account = Account::create();
$apiKey = $account->apiKeys()->create();
$this->assertNotSoftDeleted($apiKey);
$this->artisan('api-key:delete', [
'--id' => $apiKey->getKey()
]);
$this->assertSoftDeleted($apiKey);
}
}
================================================
FILE: tests/Unit/Console/Commands/GenerateApiKey.php
================================================
<?php
namespace Givebutter\Tests\Unit\Console\Commands;
use Givebutter\Tests\Support\Account;
use Givebutter\Tests\TestCase;
use Illuminate\Support\Facades\Artisan;
class GenerateApiKey extends TestCase
{
/** @test */
public function generate_api_key(): void
{
// Arrange
$account = Account::create();
$this->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
================================================
<?php
namespace Givebutter\Tests\Unit\Console\Commands;
use Givebutter\LaravelKeyable\Models\ApiKey;
use Givebutter\Tests\Support\Account;
use Givebutter\Tests\TestCase;
use Illuminate\Database\Eloquent\Model;
class HashApiKeys extends TestCase
{
/** @test */
public function hash_api_keys(): 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');
// 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
================================================
<?php
namespace Givebutter\Tests\Unit\Models;
use Givebutter\LaravelKeyable\Models\ApiKey;
use Givebutter\Tests\Support\Account;
use Givebutter\Tests\TestCase;
class ApiKeyTest extends TestCase
{
/** @test */
public function create_new_api_key(): void
{
$account = Account::create();
$apiKey = ApiKey::create([
'keyable_id' => $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',
]);
}
}
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
SYMBOL INDEX (96 symbols across 28 files)
FILE: database/migrations/2019_04_09_225232_create_api_keys_table.php
class CreateApiKeysTable (line 7) | class CreateApiKeysTable extends Migration
method up (line 14) | public function up()
method down (line 33) | public function down()
FILE: src/Auth/AuthorizesKeyableRequests.php
type AuthorizesKeyableRequests (line 9) | trait AuthorizesKeyableRequests
method authorizeKeyable (line 16) | public function authorizeKeyable($ability, $object)
method getKeyablePolicy (line 45) | public function getKeyablePolicy($object)
FILE: src/Auth/Keyable.php
class Keyable (line 5) | class Keyable
method registerKeyablePolicies (line 9) | public function registerKeyablePolicies($policies)
method getKeyablePolicies (line 14) | public function getKeyablePolicies()
FILE: src/Console/Commands/DeleteApiKey.php
class DeleteApiKey (line 8) | class DeleteApiKey extends Command
method __construct (line 27) | public function __construct()
method handle (line 37) | public function handle()
FILE: src/Console/Commands/GenerateApiKey.php
class GenerateApiKey (line 8) | class GenerateApiKey extends Command
method __construct (line 30) | public function __construct()
method handle (line 40) | public function handle()
FILE: src/Console/Commands/HashApiKeys.php
class HashApiKeys (line 10) | class HashApiKeys extends Command
method handle (line 31) | public function handle()
FILE: src/Events/KeyableAuthenticated.php
class KeyableAuthenticated (line 7) | class KeyableAuthenticated
method __construct (line 9) | public function __construct(public ApiKey $apiKey)
FILE: src/Facades/Keyable.php
class Keyable (line 8) | class Keyable extends Facade
method getFacadeAccessor (line 15) | protected static function getFacadeAccessor(): string
FILE: src/Http/Middleware/AuthenticateApiKey.php
class AuthenticateApiKey (line 9) | class AuthenticateApiKey
method handle (line 20) | public function handle($request, Closure $next, $guard = null)
method getKeyFromRequest (line 84) | protected function getKeyFromRequest($request)
method unauthorizedResponse (line 101) | protected function unauthorizedResponse()
FILE: src/Http/Middleware/EnforceKeyableScope.php
class EnforceKeyableScope (line 11) | class EnforceKeyableScope
method handle (line 22) | public function handle($request, Closure $next, $guard = null)
method getParameterName (line 50) | protected static function getParameterName($name, $parameters)
FILE: src/Keyable.php
type Keyable (line 8) | trait Keyable
method apiKeys (line 10) | public function apiKeys()
method createApiKey (line 15) | public function createApiKey(array $attributes = []): NewApiKey
FILE: src/KeyableServiceProvider.php
class KeyableServiceProvider (line 15) | class KeyableServiceProvider extends ServiceProvider
method boot (line 22) | public function boot(Router $router)
method register (line 42) | public function register()
method registerCommands (line 47) | protected function registerCommands()
method registerMiddleware (line 65) | protected function registerMiddleware(Router $router)
method registerMacros (line 77) | protected function registerMacros()
FILE: src/Models/ApiKey.php
class ApiKey (line 10) | class ApiKey extends Model
method boot (line 30) | public static function boot()
method keyable (line 45) | public function keyable()
method generate (line 55) | public static function generate()
method getByKey (line 71) | public static function getByKey($key)
method keyExists (line 85) | public static function keyExists($key)
method markAsUsed (line 95) | public function markAsUsed()
method scopeOfKey (line 102) | public function scopeOfKey(Builder $query, string $key): Builder
FILE: src/NewApiKey.php
class NewApiKey (line 7) | class NewApiKey
method __construct (line 9) | public function __construct(
FILE: tests/Feature/AuthenticateApiKey.php
class AuthenticateApiKey (line 10) | class AuthenticateApiKey extends TestCase
method request_with_api_key_responds_ok (line 13) | public function request_with_api_key_responds_ok()
method request_with_valid_api_key_without_id_prefix_responds_ok (line 27) | public function request_with_valid_api_key_without_id_prefix_responds_...
method request_having_api_key_with_valid_but_mismatched_id_and_key_responds_unauthorized (line 45) | public function request_having_api_key_with_valid_but_mismatched_id_an...
method request_without_api_key_responds_unauthorized (line 77) | public function request_without_api_key_responds_unauthorized()
method throw_exception_if_unauthorized_get_request_has_forbidden_request_query_params (line 90) | public function throw_exception_if_unauthorized_get_request_has_forbid...
method throw_exception_if_unauthorized_post_request_has_forbidden_request_body_params (line 105) | public function throw_exception_if_unauthorized_post_request_has_forbi...
method throw_exception_if_unauthorized_json_get_request_has_forbidden_request_query_params (line 120) | public function throw_exception_if_unauthorized_json_get_request_has_f...
method throw_exception_if_unauthorized_json_post_request_has_forbidden_request_body_params (line 135) | public function throw_exception_if_unauthorized_json_post_request_has_...
method forbiddenRequestParams (line 146) | public function forbiddenRequestParams(): array
FILE: tests/Feature/CompatibilityMode.php
class CompatibilityMode (line 14) | class CompatibilityMode extends TestCase
method accepts_both_hashed_and_non_hashed_api_keys_when_compatibility_mode_is_on (line 17) | public function accepts_both_hashed_and_non_hashed_api_keys_when_compa...
FILE: tests/Feature/EnforceKeyableScope.php
class EnforceKeyableScope (line 13) | class EnforceKeyableScope extends TestCase
method request_with_parameter_must_be_owned_by_keyable (line 16) | public function request_with_parameter_must_be_owned_by_keyable()
method request_with_model_not_owned_by_keyable_throws_model_not_found (line 31) | public function request_with_model_not_owned_by_keyable_throws_model_n...
method works_with_resource_routes (line 47) | public function works_with_resource_routes()
method can_use_scoped_with_keyableScoped (line 81) | public function can_use_scoped_with_keyableScoped()
FILE: tests/Support/Account.php
class Account (line 8) | class Account extends Model
method posts (line 12) | public function posts()
FILE: tests/Support/Comment.php
class Comment (line 8) | class Comment extends Model
method post (line 10) | public function post()
FILE: tests/Support/CommentsController.php
class CommentsController (line 8) | class CommentsController
method show (line 10) | public function show(Request $request, Post $post, Comment $comment)
FILE: tests/Support/Migrations/create_test_tables.php
class CreateTestTables (line 7) | class CreateTestTables extends Migration
method up (line 9) | public function up()
method down (line 29) | public function down()
FILE: tests/Support/Post.php
class Post (line 9) | class Post extends Model
method account (line 11) | public function account()
method comments (line 16) | public function comments()
FILE: tests/Support/PostsController.php
class PostsController (line 8) | class PostsController
method show (line 10) | public function show(Request $request, Post $post)
FILE: tests/TestCase.php
class TestCase (line 11) | class TestCase extends OrchestraTestCase
method setUp (line 13) | public function setUp(): void
method getPackageProviders (line 28) | protected function getPackageProviders($app)
method getEnvironmentSetUp (line 35) | protected function getEnvironmentSetUp($app)
method setUpDatabase (line 46) | protected function setUpDatabase($app)
method runMigrationStub (line 57) | protected function runMigrationStub()
method prepareDatabaseForHasCustomFieldsModel (line 63) | protected function prepareDatabaseForHasCustomFieldsModel()
method resetDatabase (line 69) | protected function resetDatabase()
FILE: tests/Unit/Console/Commands/DeleteApiKey.php
class DeleteApiKey (line 8) | class DeleteApiKey extends TestCase
method delete_api_key (line 11) | public function delete_api_key(): void
FILE: tests/Unit/Console/Commands/GenerateApiKey.php
class GenerateApiKey (line 9) | class GenerateApiKey extends TestCase
method generate_api_key (line 12) | public function generate_api_key(): void
FILE: tests/Unit/Console/Commands/HashApiKeys.php
class HashApiKeys (line 10) | class HashApiKeys extends TestCase
method hash_api_keys (line 13) | public function hash_api_keys(): void
method hash_one_api_key_at_a_time (line 69) | public function hash_one_api_key_at_a_time(): void
method api_key_is_not_hashed_more_than_once (line 127) | public function api_key_is_not_hashed_more_than_once(): void
FILE: tests/Unit/Models/ApiKeyTest.php
class ApiKeyTest (line 9) | class ApiKeyTest extends TestCase
method create_new_api_key (line 12) | public function create_new_api_key(): void
Condensed preview — 34 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (61K chars).
[
{
"path": ".gitignore",
"chars": 59,
"preview": "/vendor\ncomposer.phar\ncomposer.lock\n.DS_Store\n.php_cs.cache"
},
{
"path": "LICENSE.md",
"chars": 1095,
"preview": "The MIT License\n\nCopyright (c) Givebutter, Inc. https://givebutter.com\n\nPermission is hereby granted, free of charge, to"
},
{
"path": "README.md",
"chars": 8301,
"preview": "# Laravel Keyable\r\n\r\nLaravel Keyable is a package that allows you to add API Keys to any model. This allows you to assoc"
},
{
"path": "UPGRADING.md",
"chars": 2339,
"preview": "## Upgrade guide\r\n\r\n### From 2.1.1 to 3.0.0\r\n\r\nATTENTION: It is highly recommended that you generate a backup of your da"
},
{
"path": "composer.json",
"chars": 1010,
"preview": "{\n \"name\": \"givebutter/laravel-keyable\",\n \"description\": \"Add API keys to your Laravel models\",\n \"license\": \"MI"
},
{
"path": "config/keyable.php",
"chars": 1538,
"preview": "<?php\n\nreturn [\n\n /*\n |--------------------------------------------------------------------------\n | Authentica"
},
{
"path": "database/migrations/2019_04_09_225232_create_api_keys_table.php",
"chars": 855,
"preview": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Fa"
},
{
"path": "src/Auth/AuthorizesKeyableRequests.php",
"chars": 1276,
"preview": "<?php\n\nnamespace Givebutter\\LaravelKeyable\\Auth;\n\nuse Givebutter\\LaravelKeyable\\Facades\\Keyable;\nuse Illuminate\\Auth\\Acc"
},
{
"path": "src/Auth/Keyable.php",
"chars": 291,
"preview": "<?php\n\nnamespace Givebutter\\LaravelKeyable\\Auth;\n\nclass Keyable\n{\n protected $policies;\n\n public function register"
},
{
"path": "src/Console/Commands/DeleteApiKey.php",
"chars": 904,
"preview": "<?php\n\nnamespace Givebutter\\LaravelKeyable\\Console\\Commands;\n\nuse Givebutter\\LaravelKeyable\\Models\\ApiKey;\nuse Illuminat"
},
{
"path": "src/Console/Commands/GenerateApiKey.php",
"chars": 1292,
"preview": "<?php\n\nnamespace Givebutter\\LaravelKeyable\\Console\\Commands;\n\nuse Givebutter\\LaravelKeyable\\Models\\ApiKey;\nuse Illuminat"
},
{
"path": "src/Console/Commands/HashApiKeys.php",
"chars": 1372,
"preview": "<?php\n\nnamespace Givebutter\\LaravelKeyable\\Console\\Commands;\n\nuse Givebutter\\LaravelKeyable\\Models\\ApiKey;\nuse Illuminat"
},
{
"path": "src/Events/KeyableAuthenticated.php",
"chars": 196,
"preview": "<?php\n\nnamespace Givebutter\\LaravelKeyable\\Events;\n\nuse Givebutter\\LaravelKeyable\\Models\\ApiKey;\n\nclass KeyableAuthentic"
},
{
"path": "src/Facades/Keyable.php",
"chars": 384,
"preview": "<?php\n\nnamespace Givebutter\\LaravelKeyable\\Facades;\n\nuse Givebutter\\LaravelKeyable\\Auth\\Keyable as KeyableAuth;\nuse Illu"
},
{
"path": "src/Http/Middleware/AuthenticateApiKey.php",
"chars": 2901,
"preview": "<?php\n\nnamespace Givebutter\\LaravelKeyable\\Http\\Middleware;\n\nuse Closure;\nuse Givebutter\\LaravelKeyable\\Models\\ApiKey;\nu"
},
{
"path": "src/Http/Middleware/EnforceKeyableScope.php",
"chars": 1771,
"preview": "<?php\n\nnamespace Givebutter\\LaravelKeyable\\Http\\Middleware;\n\nuse Closure;\nuse Illuminate\\Support\\Arr;\nuse Illuminate\\Sup"
},
{
"path": "src/Keyable.php",
"chars": 734,
"preview": "<?php\n\nnamespace Givebutter\\LaravelKeyable;\n\nuse Givebutter\\LaravelKeyable\\Models\\ApiKey;\nuse Illuminate\\Database\\Eloque"
},
{
"path": "src/KeyableServiceProvider.php",
"chars": 2425,
"preview": "<?php\n\nnamespace Givebutter\\LaravelKeyable;\n\nuse Illuminate\\Routing\\Route;\nuse Illuminate\\Routing\\Router;\nuse Illuminate"
},
{
"path": "src/Models/ApiKey.php",
"chars": 3234,
"preview": "<?php\n\nnamespace Givebutter\\LaravelKeyable\\Models;\n\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Database\\El"
},
{
"path": "src/NewApiKey.php",
"chars": 240,
"preview": "<?php\n\nnamespace Givebutter\\LaravelKeyable;\n\nuse Givebutter\\LaravelKeyable\\Models\\ApiKey;\n\nclass NewApiKey\n{\n public "
},
{
"path": "tests/Feature/AuthenticateApiKey.php",
"chars": 5021,
"preview": "<?php\n\nnamespace Givebutter\\Tests\\Feature;\n\nuse Givebutter\\LaravelKeyable\\Exceptions\\ForbidenRequestParamException;\nuse "
},
{
"path": "tests/Feature/CompatibilityMode.php",
"chars": 3314,
"preview": "<?php\n\nnamespace Givebutter\\Tests\\Feature;\n\nuse Givebutter\\LaravelKeyable\\Models\\ApiKey;\nuse Givebutter\\Tests\\Support\\Ac"
},
{
"path": "tests/Feature/EnforceKeyableScope.php",
"chars": 3838,
"preview": "<?php\n\nnamespace Givebutter\\Tests\\Feature;\n\nuse Illuminate\\Http\\Request;\nuse Givebutter\\Tests\\TestCase;\nuse Givebutter\\T"
},
{
"path": "tests/Support/Account.php",
"chars": 258,
"preview": "<?php\n\nnamespace Givebutter\\Tests\\Support;\n\nuse Givebutter\\LaravelKeyable\\Keyable;\nuse Illuminate\\Database\\Eloquent\\Mode"
},
{
"path": "tests/Support/Comment.php",
"chars": 237,
"preview": "<?php\n\nnamespace Givebutter\\Tests\\Support;\n\nuse Givebutter\\Tests\\Support\\Post;\nuse Illuminate\\Database\\Eloquent\\Model;\n\n"
},
{
"path": "tests/Support/CommentsController.php",
"chars": 265,
"preview": "<?php\n\nnamespace Givebutter\\Tests\\Support;\n\nuse Illuminate\\Http\\Request;\nuse Givebutter\\Tests\\Support\\Post;\n\nclass Comme"
},
{
"path": "tests/Support/Migrations/create_test_tables.php",
"chars": 943,
"preview": "<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Fa"
},
{
"path": "tests/Support/Post.php",
"chars": 372,
"preview": "<?php\n\nnamespace Givebutter\\Tests\\Support;\n\nuse Givebutter\\Tests\\Support\\Account;\nuse Givebutter\\Tests\\Support\\Comment;\n"
},
{
"path": "tests/Support/PostsController.php",
"chars": 244,
"preview": "<?php\n\nnamespace Givebutter\\Tests\\Support;\n\nuse Illuminate\\Http\\Request;\nuse Givebutter\\Tests\\Support\\Post;\n\nclass Posts"
},
{
"path": "tests/TestCase.php",
"chars": 2039,
"preview": "<?php\n\nnamespace Givebutter\\Tests;\n\nuse Illuminate\\Support\\Str;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate"
},
{
"path": "tests/Unit/Console/Commands/DeleteApiKey.php",
"chars": 514,
"preview": "<?php\n\nnamespace Givebutter\\Tests\\Unit\\Console\\Commands;\n\nuse Givebutter\\Tests\\Support\\Account;\nuse Givebutter\\Tests\\Tes"
},
{
"path": "tests/Unit/Console/Commands/GenerateApiKey.php",
"chars": 1056,
"preview": "<?php\n\nnamespace Givebutter\\Tests\\Unit\\Console\\Commands;\n\nuse Givebutter\\Tests\\Support\\Account;\nuse Givebutter\\Tests\\Tes"
},
{
"path": "tests/Unit/Console/Commands/HashApiKeys.php",
"chars": 5017,
"preview": "<?php\n\nnamespace Givebutter\\Tests\\Unit\\Console\\Commands;\n\nuse Givebutter\\LaravelKeyable\\Models\\ApiKey;\nuse Givebutter\\Te"
},
{
"path": "tests/Unit/Models/ApiKeyTest.php",
"chars": 746,
"preview": "<?php\n\nnamespace Givebutter\\Tests\\Unit\\Models;\n\nuse Givebutter\\LaravelKeyable\\Models\\ApiKey;\nuse Givebutter\\Tests\\Suppor"
}
]
About this extraction
This page contains the full source code of the givebutter/laravel-keyable GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 34 files (54.8 KB), approximately 14.2k tokens, and a symbol index with 96 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.