Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions app/Http/Controllers/ApiKeySettingsController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

namespace App\Http\Controllers;

use App\Models\ApiKey;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redirect;

class ApiKeySettingsController extends Controller
{
public function edit(Request $request): View
{
return view('settings.api-keys', [
'keys' => $request->user()->apiKeys()->orderBy('id')->get(),
]);
}

public function store(Request $request): RedirectResponse
{
$data = $request->validate(['name' => ['required', 'string', 'max:100']]);

[, $plain] = ApiKey::issue($request->user(), $data['name']);

return Redirect::route('settings.api-keys.edit')
->with('status', 'api-key-made')
->with('api_key_plain', $plain);
}

public function revoke(Request $request, ApiKey $apiKey): RedirectResponse
{
abort_unless($apiKey->user_id === $request->user()->id, 404);

$apiKey->revoke();

return Redirect::route('settings.api-keys.edit')->with('status', 'api-key-revoked');
}
}
47 changes: 47 additions & 0 deletions app/Http/Middleware/AuthenticateApiKey.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

namespace App\Http\Middleware;

use App\Models\ApiKey;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Symfony\Component\HttpFoundation\Response;

/**
* Resolves the issuer from a bearer API key and rate limits per key.
* Errors use the API's error format: {"error": {"code", "message"}}.
*/
class AuthenticateApiKey
{
public function handle(Request $request, Closure $next): Response
{
$plain = $request->bearerToken();
$key = $plain ? ApiKey::findByPlain($plain) : null;

if (! $key) {
return response()->json(['error' => [
'code' => 'unauthorized',
'message' => 'Missing, unknown, or revoked API key.',
]], 401);
}

$bucket = 'api-key:' . $key->id;
$limit = config('api.rate_limit_per_minute');
if (RateLimiter::tooManyAttempts($bucket, $limit)) {
$retry = RateLimiter::availableIn($bucket);

return response()->json(['error' => [
'code' => 'rate_limited',
'message' => "Too many requests. Retry in {$retry} seconds.",
]], 429, ['Retry-After' => $retry]);
}
RateLimiter::hit($bucket, 60);

$key->touchUsed();
$request->setUserResolver(fn () => $key->user);
$request->attributes->set('api_key', $key);

return $next($request);
}
}
57 changes: 57 additions & 0 deletions app/Models/ApiKey.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;

/**
* A developer API key. Only the SHA-256 hash is stored; the plain key is
* returned once from issue() and never again.
*/
class ApiKey extends Model
{
protected $fillable = ['user_id', 'name', 'key_hash', 'last_used_at', 'revoked_at'];

protected $hidden = ['key_hash'];

protected $casts = [
'last_used_at' => 'datetime',
'revoked_at' => 'datetime',
];

/** Make a key for the issuer. Returns [model, plain key]. */
public static function issue(User $issuer, string $name): array
{
$plain = 'cz_' . Str::random(40);

$key = $issuer->apiKeys()->create([
'name' => $name,
'key_hash' => hash('sha256', $plain),
]);

return [$key, $plain];
}

/** The live key matching a plain key, or null. */
public static function findByPlain(string $plain): ?self
{
return static::where('key_hash', hash('sha256', $plain))->whereNull('revoked_at')->first();
}

public function revoke(): void
{
$this->forceFill(['revoked_at' => now()])->save();
}

public function touchUsed(): void
{
$this->forceFill(['last_used_at' => now()])->save();
}

public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
5 changes: 5 additions & 0 deletions app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,11 @@ public function invoices()
{
return $this->hasMany(\App\Models\Invoice::class);
}
public function apiKeys()
{
return $this->hasMany(ApiKey::class);
}

public function walletSetting()
{
return $this->hasOne(WalletSetting::class);
Expand Down
2 changes: 2 additions & 0 deletions bootstrap/app.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@
ReassignInvoiceAddresses::class,
])
->withMiddleware(function (Middleware $middleware): void {
$middleware->alias(['api.key' => \App\Http\Middleware\AuthenticateApiKey::class]);

// A session whose account lost approval (gate on) or was banned is
// dropped on its next request, not at its next login.
$middleware->web(append: [
Expand Down
6 changes: 6 additions & 0 deletions config/api.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?php

return [
// Requests allowed per API key per minute.
'rate_limit_per_minute' => (int) env('API_RATE_LIMIT_PER_MINUTE', 60),
];
26 changes: 26 additions & 0 deletions database/migrations/2026_10_04_120000_create_api_keys_table.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up(): void
{
Schema::create('api_keys', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->string('key_hash', 64)->unique();
$table->timestamp('last_used_at')->nullable();
$table->timestamp('revoked_at')->nullable();
$table->timestamps();
});
}

public function down(): void
{
Schema::dropIfExists('api_keys');
}
};
28 changes: 14 additions & 14 deletions docs/strategies/22.2_API_KEYS_AND_CONTRACT.md
Original file line number Diff line number Diff line change
@@ -1,32 +1,32 @@
# M22 Phase 2 — API Keys and Contract

Status: Approved; §1 active.
Status: Approved; §3 active.
Parent: [M22](../milestones/22_LINE_ITEMS_DEVELOPER_API.md), Phase 2.
Spec: [DEVELOPER_API.md](../specs/DEVELOPER_API.md).

Goal: an issuer can make and revoke API keys, requests with a key are authenticated and rate limited, and the request and response contract is written down before the endpoints exist.

Parallel work: §1 and §2 can run side by side. §3 waits for both.

## 1. [ ] Keys %<3020>
## 1. [x] Keys %<3020>

1. [ ] Add the API key table and model: issuer, name, key hash, last used, revoked at. %<3021>
2. [ ] Issuer settings page: make a key, see it once in full, see the list with last used, revoke one. %<3022>
3. [ ] Authenticate API requests by bearer key and resolve the issuer; a revoked or unknown key is refused. %<3023>
4. [ ] Rate limit per key; a limited request says when to retry. %<3024>
1. [x] Add the API key table and model: issuer, name, key hash, last used, revoked at. %<3021>
2. [x] Issuer settings page: make a key, see it once in full, see the list with last used, revoke one. %<3022>
3. [x] Authenticate API requests by bearer key and resolve the issuer; a revoked or unknown key is refused. %<3023>
4. [x] Rate limit per key; a limited request says when to retry. %<3024>

## 2. [ ] Contract %<3025>
## 2. [x] Contract %<3025>

1. [x] Write `docs/API.md`: create, read, edit draft, delete, and send; one request and one response example each. %<3026>
2. [ ] Define the invoice representation: lines, total, public link, payment details, mail state, payment state. %<3027>
3. [ ] Define the idempotency rule for create: the same request key returns the first invoice. %<3028>
4. [ ] Define the error format: one stable code plus the field or rule that failed, with the list of codes. %<3029>
1. [x] Write `docs/API.md`: create, read, edit draft, delete, and send; one request and one response example each. Defines §2.2–2.4 too. %<3026>
2. [x] Define the invoice representation: lines, total, public link, payment details, mail state, payment state. %<3027>
3. [x] Define the idempotency rule for create: the same request key returns the first invoice. %<3028>
4. [x] Define the error format: one stable code plus the field or rule that failed, with the list of codes. %<3029>

## 3. [ ] Verify %<3030>

1. [ ] [Agent] Targeted tests: key creation, one-time reveal, revoke, refused requests, rate limit, and the error format. %<3031>
2. [ ] [Agent] Full Sail suite. %<3032>
3. [ ] [Subagent] Browser check of the key settings page on desktop and mobile widths. %<3033>
1. [x] [Agent] Targeted tests: key creation, one-time reveal, revoke, refused requests, rate limit, and the error format. 9 pass. %<3031>
2. [x] [Agent] Full Sail suite. 662 pass. %<3032>
3. [x] [Subagent] Browser check of the key settings page on desktop and mobile widths. Pass; error line moved below the form row so the button holds still. %<3033>
4. [ ] [User] Review the key settings page on dev and the contract in `docs/API.md`. %<3034>
5. [ ] [Agent] Record the dev verdict in the milestone doc. %<3035>

Expand Down
85 changes: 85 additions & 0 deletions resources/views/settings/api-keys.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<x-app-layout>
<x-slot name="header">
<div class="flex flex-col">
<h2 class="mb-4 text-xl font-semibold leading-tight text-gray-800">
Settings
</h2>
<div class="mt-8">
@include('settings.partials.tabs')
</div>
</div>
</x-slot>

<div class="py-8">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
<div class="overflow-hidden bg-white shadow sm:rounded-lg">
<div class="p-6 space-y-6">
@if (session('status') === 'api-key-made')
<div class="rounded border border-green-300 bg-green-50 p-3 text-sm text-green-800 space-y-2" style="border-color: currentColor;">
<p>Your new key. Copy it now; it will not be shown again.</p>
<code id="newApiKey" class="block select-all break-all rounded bg-white px-2 py-1 font-mono text-sm text-gray-900">{{ session('api_key_plain') }}</code>
</div>
@elseif (session('status') === 'api-key-revoked')
<div class="rounded border border-green-300 bg-green-50 p-3 text-sm text-green-800" style="border-color: currentColor;">
Key revoked.
</div>
@endif

<div class="space-y-2">
<h3 class="text-sm font-semibold text-gray-700">API keys</h3>
<p class="text-xs text-gray-600">
A key lets your own software create and read invoices for this account. See the <a href="https://github.com/n8bar/CryptoZing/blob/main/docs/API.md" class="underline">API reference</a>.
</p>
</div>

<form method="POST" action="{{ route('settings.api-keys.store') }}">
@csrf
<div class="flex flex-wrap items-end gap-3">
<div>
<x-input-label for="name" value="Key name" />
<x-text-input id="name" name="name" type="text" class="mt-1 block w-64" :value="old('name')" required maxlength="100" placeholder="Shop website" />
</div>
<x-primary-button>Make key</x-primary-button>
</div>
<x-input-error :messages="$errors->get('name')" class="mt-2" />
</form>

@if ($keys->isEmpty())
<p class="text-sm text-gray-600">No keys yet.</p>
@else
<table class="w-full text-sm">
<thead>
<tr class="text-left text-xs uppercase tracking-wide text-gray-500">
<th class="py-2">Name</th>
<th class="py-2">Made</th>
<th class="py-2">Last used</th>
<th class="py-2"></th>
</tr>
</thead>
<tbody>
@foreach ($keys as $key)
<tr class="border-t border-gray-200 {{ $key->revoked_at ? 'text-gray-400' : '' }}">
<td class="py-2 pr-3">{{ $key->name }}</td>
<td class="py-2 pr-3">{{ $key->created_at->format('D, Y-m-d H:i') }}</td>
<td class="py-2 pr-3">{{ $key->last_used_at?->format('D, Y-m-d H:i') ?? 'Never used' }}</td>
<td class="py-2 text-right">
@if ($key->revoked_at)
Revoked {{ $key->revoked_at->format('Y-m-d') }}
@else
<form method="POST" action="{{ route('settings.api-keys.revoke', $key) }}">
@csrf
@method('DELETE')
<x-danger-button type="submit" class="!px-3 !py-1.5 !text-xs" aria-label="Revoke {{ $key->name }}">Revoke</x-danger-button>
</form>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
@endif
</div>
</div>
</div>
</div>
</x-app-layout>
5 changes: 5 additions & 0 deletions resources/views/settings/partials/tabs.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@
'href' => route('settings.notifications.edit'),
'active' => request()->routeIs('settings.notifications.*'),
],
[
'label' => 'API keys',
'href' => route('settings.api-keys.edit'),
'active' => request()->routeIs('settings.api-keys.*'),
],
];
@endphp

Expand Down
4 changes: 4 additions & 0 deletions routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use App\Http\Controllers\InvoiceController;
use App\Http\Controllers\WalletSettingsController;
use App\Http\Controllers\InvoiceSettingsController;
use App\Http\Controllers\ApiKeySettingsController;
use App\Http\Controllers\NotificationSettingsController;
use App\Http\Controllers\HealthController;
use App\Http\Controllers\LegalController;
Expand Down Expand Up @@ -104,6 +105,9 @@
Route::get('/settings/notifications', [NotificationSettingsController::class, 'edit'])->name('settings.notifications.edit');
Route::patch('/settings/notifications', [NotificationSettingsController::class, 'update'])->name('settings.notifications.update');
Route::post('/settings/notifications/test-email', [NotificationSettingsController::class, 'sendPreview'])->name('settings.notifications.preview');
Route::get('/settings/api-keys', [ApiKeySettingsController::class, 'edit'])->name('settings.api-keys.edit');
Route::post('/settings/api-keys', [ApiKeySettingsController::class, 'store'])->name('settings.api-keys.store');
Route::delete('/settings/api-keys/{apiKey}', [ApiKeySettingsController::class, 'revoke'])->name('settings.api-keys.revoke');
Route::get('/wallet/settings', [WalletSettingsController::class, 'edit'])->name('wallet.settings.edit');
Route::post('/wallet/settings', [WalletSettingsController::class, 'update'])
->middleware('throttle:10,1')
Expand Down
Loading
Loading