391 lines
7.4 KiB
Markdown
391 lines
7.4 KiB
Markdown
# API Authentication
|
|
|
|
The API package provides secure authentication with bcrypt-hashed API keys, scope-based permissions, and automatic key rotation.
|
|
|
|
## API Key Management
|
|
|
|
### Creating Keys
|
|
|
|
```php
|
|
use Mod\Api\Models\ApiKey;
|
|
|
|
$apiKey = ApiKey::create([
|
|
'name' => 'Mobile App Production',
|
|
'workspace_id' => $workspace->id,
|
|
'scopes' => ['posts:read', 'posts:write', 'categories:read'],
|
|
'rate_limit_tier' => 'pro',
|
|
'expires_at' => now()->addYear(),
|
|
]);
|
|
|
|
// Get plaintext key (only available once!)
|
|
$plaintext = $apiKey->plaintext_key;
|
|
// Returns: sk_live_abc123def456...
|
|
```
|
|
|
|
**Key Format:** `{prefix}_{environment}_{random}`
|
|
- Prefix: `sk` (secret key)
|
|
- Environment: `live` or `test`
|
|
- Random: 32-character string
|
|
|
|
### Secure Storage
|
|
|
|
Keys are hashed with bcrypt before storage:
|
|
|
|
```php
|
|
// Never stored in plaintext
|
|
$hash = bcrypt($plaintext);
|
|
|
|
// Stored in database
|
|
$apiKey->key_hash = $hash;
|
|
|
|
// Verification
|
|
if (Hash::check($providedKey, $apiKey->key_hash)) {
|
|
// Valid key
|
|
}
|
|
```
|
|
|
|
### Key Rotation
|
|
|
|
Rotate keys with a grace period:
|
|
|
|
```php
|
|
$newKey = $apiKey->rotate([
|
|
'grace_period_hours' => 24,
|
|
]);
|
|
|
|
// Returns new ApiKey with:
|
|
// - New plaintext key
|
|
// - Same scopes and settings
|
|
// - Old key marked for deletion after grace period
|
|
```
|
|
|
|
During the grace period, both keys work. After 24 hours, the old key is automatically deleted.
|
|
|
|
## Using API Keys
|
|
|
|
### Authorization Header
|
|
|
|
```bash
|
|
curl -H "Authorization: Bearer sk_live_abc123..." \
|
|
https://api.example.com/v1/posts
|
|
```
|
|
|
|
### Basic Auth
|
|
|
|
```bash
|
|
curl -u sk_live_abc123: \
|
|
https://api.example.com/v1/posts
|
|
```
|
|
|
|
### PHP Example
|
|
|
|
```php
|
|
use GuzzleHttp\Client;
|
|
|
|
$client = new Client([
|
|
'base_uri' => 'https://api.example.com',
|
|
'headers' => [
|
|
'Authorization' => "Bearer {$apiKey}",
|
|
'Accept' => 'application/json',
|
|
],
|
|
]);
|
|
|
|
$response = $client->get('/v1/posts');
|
|
```
|
|
|
|
### JavaScript Example
|
|
|
|
```javascript
|
|
const response = await fetch('https://api.example.com/v1/posts', {
|
|
headers: {
|
|
'Authorization': `Bearer ${apiKey}`,
|
|
'Accept': 'application/json'
|
|
}
|
|
});
|
|
```
|
|
|
|
## Scopes & Permissions
|
|
|
|
### Defining Scopes
|
|
|
|
```php
|
|
$apiKey = ApiKey::create([
|
|
'scopes' => [
|
|
'posts:read', // Read posts
|
|
'posts:write', // Create/update posts
|
|
'posts:delete', // Delete posts
|
|
'categories:read', // Read categories
|
|
],
|
|
]);
|
|
```
|
|
|
|
### Common Scopes
|
|
|
|
| Scope | Description |
|
|
|-------|-------------|
|
|
| `{resource}:read` | Read access |
|
|
| `{resource}:write` | Create and update |
|
|
| `{resource}:delete` | Delete access |
|
|
| `{resource}:*` | All permissions for resource |
|
|
| `*` | Full access (use sparingly!) |
|
|
|
|
### Wildcard Scopes
|
|
|
|
```php
|
|
// All post permissions
|
|
'scopes' => ['posts:*']
|
|
|
|
// Read access to all resources
|
|
'scopes' => ['*:read']
|
|
|
|
// Full access (admin only!)
|
|
'scopes' => ['*']
|
|
```
|
|
|
|
### Scope Enforcement
|
|
|
|
Protect routes with scope middleware:
|
|
|
|
```php
|
|
Route::middleware('scope:posts:write')
|
|
->post('/posts', [PostController::class, 'store']);
|
|
|
|
Route::middleware('scope:posts:delete')
|
|
->delete('/posts/{id}', [PostController::class, 'destroy']);
|
|
```
|
|
|
|
### Check Scopes in Controllers
|
|
|
|
```php
|
|
public function store(Request $request)
|
|
{
|
|
if (!$request->user()->tokenCan('posts:write')) {
|
|
return response()->json([
|
|
'error' => 'Insufficient permissions',
|
|
'required_scope' => 'posts:write',
|
|
], 403);
|
|
}
|
|
|
|
return Post::create($request->validated());
|
|
}
|
|
```
|
|
|
|
## Rate Limiting
|
|
|
|
Keys are rate-limited based on tier:
|
|
|
|
```php
|
|
// config/api.php
|
|
'rate_limits' => [
|
|
'free' => ['requests' => 1000, 'per' => 'hour'],
|
|
'pro' => ['requests' => 10000, 'per' => 'hour'],
|
|
'business' => ['requests' => 50000, 'per' => 'hour'],
|
|
'enterprise' => ['requests' => null], // Unlimited
|
|
],
|
|
```
|
|
|
|
Rate limit headers included in responses:
|
|
|
|
```
|
|
X-RateLimit-Limit: 10000
|
|
X-RateLimit-Remaining: 9847
|
|
X-RateLimit-Reset: 1640995200
|
|
```
|
|
|
|
[Learn more about Rate Limiting →](/packages/api/rate-limiting)
|
|
|
|
## Key Expiration
|
|
|
|
### Set Expiration
|
|
|
|
```php
|
|
$apiKey = ApiKey::create([
|
|
'expires_at' => now()->addMonths(6),
|
|
]);
|
|
```
|
|
|
|
### Check Expiration
|
|
|
|
```php
|
|
if ($apiKey->isExpired()) {
|
|
return response()->json(['error' => 'API key expired'], 401);
|
|
}
|
|
```
|
|
|
|
### Auto-Cleanup
|
|
|
|
Expired keys are automatically cleaned up:
|
|
|
|
```bash
|
|
php artisan api:prune-expired-keys
|
|
```
|
|
|
|
## Environment-Specific Keys
|
|
|
|
### Test Keys
|
|
|
|
```php
|
|
$testKey = ApiKey::create([
|
|
'name' => 'Development Key',
|
|
'environment' => 'test',
|
|
]);
|
|
|
|
// Key prefix: sk_test_...
|
|
```
|
|
|
|
Test keys:
|
|
- Don't affect production data
|
|
- Higher rate limits
|
|
- Clearly marked in UI
|
|
- Easy to identify and delete
|
|
|
|
### Live Keys
|
|
|
|
```php
|
|
$liveKey = ApiKey::create([
|
|
'environment' => 'live',
|
|
]);
|
|
|
|
// Key prefix: sk_live_...
|
|
```
|
|
|
|
## Middleware
|
|
|
|
### API Authentication
|
|
|
|
```php
|
|
Route::middleware('auth:api')->group(function () {
|
|
// Protected routes
|
|
});
|
|
```
|
|
|
|
### Scope Enforcement
|
|
|
|
```php
|
|
use Mod\Api\Middleware\EnforceApiScope;
|
|
|
|
Route::middleware([EnforceApiScope::class.':posts:write'])
|
|
->post('/posts', [PostController::class, 'store']);
|
|
```
|
|
|
|
### Rate Limiting
|
|
|
|
```php
|
|
use Mod\Api\Middleware\RateLimitApi;
|
|
|
|
Route::middleware(RateLimitApi::class)->group(function () {
|
|
// Rate-limited routes
|
|
});
|
|
```
|
|
|
|
## Security Best Practices
|
|
|
|
### 1. Minimum Required Scopes
|
|
|
|
```php
|
|
// ✅ Good - specific scopes
|
|
'scopes' => ['posts:read', 'categories:read']
|
|
|
|
// ❌ Bad - excessive permissions
|
|
'scopes' => ['*']
|
|
```
|
|
|
|
### 2. Rotate Regularly
|
|
|
|
```php
|
|
// Rotate every 90 days
|
|
if ($apiKey->created_at->diffInDays() > 90) {
|
|
$newKey = $apiKey->rotate();
|
|
// Notify user of new key
|
|
}
|
|
```
|
|
|
|
### 3. Use Separate Keys Per Client
|
|
|
|
```php
|
|
// ✅ Good - separate keys
|
|
ApiKey::create(['name' => 'iOS App']);
|
|
ApiKey::create(['name' => 'Android App']);
|
|
ApiKey::create(['name' => 'Web App']);
|
|
|
|
// ❌ Bad - shared key
|
|
ApiKey::create(['name' => 'All Mobile Apps']);
|
|
```
|
|
|
|
### 4. Set Expiration
|
|
|
|
```php
|
|
// ✅ Good - temporary access
|
|
'expires_at' => now()->addMonths(6)
|
|
|
|
// ❌ Bad - never expires
|
|
'expires_at' => null
|
|
```
|
|
|
|
### 5. Monitor Usage
|
|
|
|
```php
|
|
$usage = ApiKey::find($id)->usage()
|
|
->whereBetween('created_at', [now()->subDays(7), now()])
|
|
->count();
|
|
|
|
if ($usage > $threshold) {
|
|
// Alert admin
|
|
}
|
|
```
|
|
|
|
## Testing
|
|
|
|
```php
|
|
<?php
|
|
|
|
namespace Tests\Feature\Api;
|
|
|
|
use Tests\TestCase;
|
|
use Mod\Api\Models\ApiKey;
|
|
|
|
class ApiKeyAuthTest extends TestCase
|
|
{
|
|
public function test_authenticates_with_valid_key(): void
|
|
{
|
|
$apiKey = ApiKey::factory()->create([
|
|
'scopes' => ['posts:read'],
|
|
]);
|
|
|
|
$response = $this->withHeaders([
|
|
'Authorization' => "Bearer {$apiKey->plaintext_key}",
|
|
])->getJson('/api/v1/posts');
|
|
|
|
$response->assertOk();
|
|
}
|
|
|
|
public function test_rejects_invalid_key(): void
|
|
{
|
|
$response = $this->withHeaders([
|
|
'Authorization' => 'Bearer invalid_key',
|
|
])->getJson('/api/v1/posts');
|
|
|
|
$response->assertUnauthorized();
|
|
}
|
|
|
|
public function test_enforces_scopes(): void
|
|
{
|
|
$apiKey = ApiKey::factory()->create([
|
|
'scopes' => ['posts:read'], // No write permission
|
|
]);
|
|
|
|
$response = $this->withHeaders([
|
|
'Authorization' => "Bearer {$apiKey->plaintext_key}",
|
|
])->postJson('/api/v1/posts', ['title' => 'Test']);
|
|
|
|
$response->assertForbidden();
|
|
}
|
|
}
|
|
```
|
|
|
|
## Learn More
|
|
|
|
- [Rate Limiting →](/packages/api/rate-limiting)
|
|
- [Scopes →](/packages/api/scopes)
|
|
- [Webhooks →](/packages/api/webhooks)
|
|
- [API Reference →](/api/authentication)
|