Skip to content

Commit fe26657

Browse files
authored
Merge pull request #16 from givebutter/givebutter/api-key-name
Add the ability to give a name to an api key and also hash the token before storing them
2 parents fae22db + 52c97c4 commit fe26657

18 files changed

+631
-22
lines changed

README.md

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,33 @@ protected function mapApiRoutes()
5656

5757
The middleware will authenticate API requests, ensuring they contain an API key that is valid.
5858

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+
5986
### Accessing keyable models in your controllers
6087
The model associated with the key will be attached to the incoming request as ```keyable```:
6188

@@ -263,14 +290,18 @@ Route::get('/users/{user}/posts/{post}', function (User $user, Post $post) {
263290
Generate an API key:
264291

265292
```bash
266-
php artisan api-key:generate --id=1 --type="App\Models\Account"
293+
php artisan api-key:generate --id=1 --type="App\Models\Account" --name="My api key"
267294
```
268295

269296
Delete an API key:
270297
```bash
271298
php artisan api-key:delete --id=12345
272299
```
273300

301+
## Upgrading
302+
303+
Please see [UPGRADING](UPGRADING.md) for details.
304+
274305
## Security
275306

276307
If you discover any security related issues, please email [liran@givebutter.com](mailto:liran@givebutter.com).

UPGRADING.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
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.

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121
"minimum-stability": "dev",
2222
"prefer-stable": true,
2323
"require": {
24-
"php": "^7.0|^8.0"
24+
"php": "^7.0|^8.0",
25+
"doctrine/dbal": "^3.7"
2526
},
2627
"autoload": {
2728
"psr-4": {

config/keyable.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,25 @@
2828

2929
'allow_empty_models' => false,
3030

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+
3152
];

database/migrations/2019_04_09_225232_create_api_keys_table.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ public function up()
1616
Schema::create('api_keys', function (Blueprint $table) {
1717
$table->increments('id');
1818
$table->nullableMorphs('keyable');
19-
$table->string('key', 40);
19+
$table->string('name')->nullable();
20+
$table->string('key', 64);
2021
$table->dateTime('last_used_at')->nullable();
2122
$table->timestamps();
2223
$table->softDeletes();

src/Console/Commands/GenerateApiKey.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ class GenerateApiKey extends Command
1414
*/
1515
protected $signature = 'api-key:generate
1616
{--id= : ID of the model you want to bind to this API key}
17-
{--type= : The class name of the model you want to bind to this API key}';
17+
{--type= : The class name of the model you want to bind to this API key}
18+
{--name= : The name you want to give to this API key}';
1819

1920
/**
2021
* The console command description.
@@ -41,8 +42,9 @@ public function handle()
4142
$apiKey = (new ApiKey)->create([
4243
'keyable_id' => $this->option('id'),
4344
'keyable_type' => $this->option('type'),
45+
'name' => $this->option('name'),
4446
]);
4547

46-
$this->info('The following API key was created: ' . $apiKey->key);
48+
$this->info('The following API key was created: ' . "{$apiKey->getKey()}|{$apiKey->plainTextApiKey}");
4749
}
4850
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
namespace Givebutter\LaravelKeyable\Console\Commands;
4+
5+
use Givebutter\LaravelKeyable\Models\ApiKey;
6+
use Illuminate\Console\Command;
7+
use Illuminate\Database\Eloquent\Builder;
8+
use Illuminate\Support\Facades\DB;
9+
10+
class HashApiKeys extends Command
11+
{
12+
/**
13+
* The name and signature of the console command.
14+
*
15+
* @var string
16+
*/
17+
protected $signature = 'api-key:hash {--id= : ID of the API key you want to hash}';
18+
19+
/**
20+
* The console command description.
21+
*
22+
* @var string
23+
*/
24+
protected $description = 'Hash existing API keys';
25+
26+
/**
27+
* Execute the console command.
28+
*
29+
* @return mixed
30+
*/
31+
public function handle()
32+
{
33+
DB::transaction(function () {
34+
ApiKey::query()
35+
->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+
}

src/Keyable.php

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace Givebutter\LaravelKeyable;
44

55
use Givebutter\LaravelKeyable\Models\ApiKey;
6+
use Illuminate\Database\Eloquent\Model;
67

78
trait Keyable
89
{
@@ -11,8 +12,17 @@ public function apiKeys()
1112
return $this->morphMany(ApiKey::class, 'keyable');
1213
}
1314

14-
public function createApiKey()
15+
public function createApiKey(array $attributes = []): NewApiKey
1516
{
16-
return $this->apiKeys()->create([]);
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}");
1727
}
1828
}

src/KeyableServiceProvider.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use Illuminate\Routing\PendingResourceRegistration;
99
use Givebutter\LaravelKeyable\Console\Commands\DeleteApiKey;
1010
use Givebutter\LaravelKeyable\Console\Commands\GenerateApiKey;
11+
use Givebutter\LaravelKeyable\Console\Commands\HashApiKeys;
1112
use Givebutter\LaravelKeyable\Http\Middleware\AuthenticateApiKey;
1213
use Givebutter\LaravelKeyable\Http\Middleware\EnforceKeyableScope;
1314

@@ -49,6 +50,7 @@ protected function registerCommands()
4950
$this->commands([
5051
GenerateApiKey::class,
5152
DeleteApiKey::class,
53+
HashApiKeys::class,
5254
]);
5355
}
5456
}

src/Models/ApiKey.php

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace Givebutter\LaravelKeyable\Models;
44

5+
use Illuminate\Database\Eloquent\Builder;
56
use Illuminate\Database\Eloquent\Model;
67
use Illuminate\Database\Eloquent\SoftDeletes;
78
use Illuminate\Support\Str;
@@ -10,25 +11,31 @@ class ApiKey extends Model
1011
{
1112
use SoftDeletes;
1213

14+
public ?string $plainTextApiKey = null;
15+
1316
protected $table = 'api_keys';
1417

1518
protected $fillable = [
16-
'key',
17-
'keyable_id',
18-
'keyable_type',
19+
'key',
20+
'keyable_id',
21+
'keyable_type',
22+
'name',
1923
'last_used_at',
2024
];
2125

2226
protected $casts = [
2327
'last_used_at' => 'datetime',
2428
];
25-
29+
2630
public static function boot()
2731
{
2832
parent::boot();
2933

30-
static::creating(function ($apiKey) {
31-
$apiKey->key = self::generate();
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+
}
3239
});
3340
}
3441

@@ -63,7 +70,7 @@ public static function generate()
6370
*/
6471
public static function getByKey($key)
6572
{
66-
return self::where('key', $key)->first();
73+
return self::ofKey($key)->first();
6774
}
6875

6976
/**
@@ -77,7 +84,7 @@ public static function getByKey($key)
7784
*/
7885
public static function keyExists($key)
7986
{
80-
return self::where('key', $key)
87+
return self::ofKey($key)
8188
->withTrashed()
8289
->first() instanceof self;
8390
}
@@ -91,4 +98,24 @@ public function markAsUsed()
9198
'last_used_at' => $this->freshTimestamp()
9299
])->save();
93100
}
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+
return $query->where('key', $key)
109+
->orWhere('key', hash('sha256', $key));
110+
});
111+
}
112+
113+
if (strpos($key, '|') === false) {
114+
return $query->where('key', hash('sha256', $key));
115+
}
116+
117+
[$id, $key] = explode('|', $key, 2);
118+
119+
return $query->where('id', $id)->where('key', hash('sha256', $key));
120+
}
94121
}

0 commit comments

Comments
 (0)