fix(api-tokens): persist expiration warning state (#10184)
This commit is contained in:
commit
76c8defef3
4 changed files with 112 additions and 12 deletions
|
|
@ -12,7 +12,6 @@
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
use Illuminate\Support\Facades\RateLimiter;
|
|
||||||
use Laravel\Horizon\Contracts\Silenced;
|
use Laravel\Horizon\Contracts\Silenced;
|
||||||
|
|
||||||
class ApiTokenExpirationWarningJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
class ApiTokenExpirationWarningJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||||
|
|
@ -29,20 +28,36 @@ public function handle(): void
|
||||||
->whereNotNull('expires_at')
|
->whereNotNull('expires_at')
|
||||||
->where('expires_at', '>', now())
|
->where('expires_at', '>', now())
|
||||||
->where('expires_at', '<=', now()->addDay())
|
->where('expires_at', '<=', now()->addDay())
|
||||||
|
->whereNull('api_token_expiration_warning_sent_at')
|
||||||
->where('tokenable_type', User::class)
|
->where('tokenable_type', User::class)
|
||||||
->chunkById(100, function ($tokens) {
|
->chunkById(100, function ($tokens) {
|
||||||
foreach ($tokens as $token) {
|
foreach ($tokens as $token) {
|
||||||
if (! $token->team_id) {
|
if (! $token->team_id) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
RateLimiter::attempt(
|
|
||||||
'api-token-expiring:'.$token->id,
|
$team = Team::find($token->team_id);
|
||||||
$maxAttempts = 0,
|
if (! $team) {
|
||||||
function () use ($token) {
|
continue;
|
||||||
Team::find($token->team_id)?->notify(new ApiTokenExpiringNotification($token));
|
}
|
||||||
},
|
|
||||||
$decaySeconds = 7 * 24 * 3600,
|
$warningSentAt = now();
|
||||||
);
|
|
||||||
|
$team->notify(new ApiTokenExpiringNotification($token));
|
||||||
|
|
||||||
|
$markedAsSent = PersonalAccessToken::query()
|
||||||
|
->whereKey($token->getKey())
|
||||||
|
->whereNotNull('expires_at')
|
||||||
|
->where('expires_at', '>', now())
|
||||||
|
->where('expires_at', '<=', now()->addDay())
|
||||||
|
->whereNull('api_token_expiration_warning_sent_at')
|
||||||
|
->update(['api_token_expiration_warning_sent_at' => $warningSentAt]);
|
||||||
|
|
||||||
|
if ($markedAsSent !== 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$token->forceFill(['api_token_expiration_warning_sent_at' => $warningSentAt]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,14 @@ class PersonalAccessToken extends SanctumPersonalAccessToken
|
||||||
'token',
|
'token',
|
||||||
'abilities',
|
'abilities',
|
||||||
'expires_at',
|
'expires_at',
|
||||||
|
'api_token_expiration_warning_sent_at',
|
||||||
'team_id',
|
'team_id',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'api_token_expiration_warning_sent_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('personal_access_tokens', function (Blueprint $table) {
|
||||||
|
$table->timestamp('api_token_expiration_warning_sent_at')->nullable()->after('expires_at');
|
||||||
|
$table->index(['expires_at', 'api_token_expiration_warning_sent_at'], 'personal_access_tokens_expiration_warning_index');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('personal_access_tokens', function (Blueprint $table) {
|
||||||
|
$table->dropIndex('personal_access_tokens_expiration_warning_index');
|
||||||
|
$table->dropColumn('api_token_expiration_warning_sent_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Notifications\ApiTokenExpiringNotification;
|
use App\Notifications\ApiTokenExpiringNotification;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Contracts\Notifications\Dispatcher;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Notification;
|
use Illuminate\Support\Facades\Notification;
|
||||||
|
|
@ -29,11 +30,12 @@
|
||||||
Notification::fake();
|
Notification::fake();
|
||||||
});
|
});
|
||||||
|
|
||||||
function createTokenExpiring(User $user, Team $team, ?Carbon $expiresAt): PersonalAccessToken
|
function createTokenExpiring(User $user, Team $team, ?Carbon $expiresAt, ?Carbon $warningSentAt = null): PersonalAccessToken
|
||||||
{
|
{
|
||||||
$plain = $user->createToken('t-'.uniqid(), ['read'], $expiresAt);
|
$plain = $user->createToken('t-'.uniqid(), ['read'], $expiresAt);
|
||||||
$token = $plain->accessToken;
|
$token = $plain->accessToken;
|
||||||
$token->team_id = $team->id;
|
$token->team_id = $team->id;
|
||||||
|
$token->api_token_expiration_warning_sent_at = $warningSentAt;
|
||||||
$token->save();
|
$token->save();
|
||||||
|
|
||||||
return $token->fresh();
|
return $token->fresh();
|
||||||
|
|
@ -41,14 +43,30 @@ function createTokenExpiring(User $user, Team $team, ?Carbon $expiresAt): Person
|
||||||
|
|
||||||
describe('ApiTokenExpirationWarningJob', function () {
|
describe('ApiTokenExpirationWarningJob', function () {
|
||||||
test('notifies team when token expires within 24h', function () {
|
test('notifies team when token expires within 24h', function () {
|
||||||
createTokenExpiring($this->user, $this->team, now()->addHours(23));
|
$token = createTokenExpiring($this->user, $this->team, now()->addHours(23));
|
||||||
|
|
||||||
(new ApiTokenExpirationWarningJob)->handle();
|
(new ApiTokenExpirationWarningJob)->handle();
|
||||||
|
|
||||||
Notification::assertSentTo($this->team, ApiTokenExpiringNotification::class);
|
Notification::assertSentTo($this->team, ApiTokenExpiringNotification::class);
|
||||||
|
expect($token->fresh()->api_token_expiration_warning_sent_at)->not->toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('rate limiter prevents duplicate warnings on repeat runs', function () {
|
test('does not mark token as warned when notification fails', function () {
|
||||||
|
$token = createTokenExpiring($this->user, $this->team, now()->addHours(23));
|
||||||
|
$dispatcher = Mockery::mock(Dispatcher::class);
|
||||||
|
$dispatcher->shouldReceive('send')
|
||||||
|
->once()
|
||||||
|
->andThrow(new RuntimeException('Notification failed'));
|
||||||
|
|
||||||
|
$this->app->instance(Dispatcher::class, $dispatcher);
|
||||||
|
|
||||||
|
expect(fn () => (new ApiTokenExpirationWarningJob)->handle())
|
||||||
|
->toThrow(RuntimeException::class, 'Notification failed');
|
||||||
|
|
||||||
|
expect($token->fresh()->api_token_expiration_warning_sent_at)->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('database marker prevents duplicate warnings on repeat runs', function () {
|
||||||
createTokenExpiring($this->user, $this->team, now()->addHours(12));
|
createTokenExpiring($this->user, $this->team, now()->addHours(12));
|
||||||
|
|
||||||
(new ApiTokenExpirationWarningJob)->handle();
|
(new ApiTokenExpirationWarningJob)->handle();
|
||||||
|
|
@ -57,6 +75,35 @@ function createTokenExpiring(User $user, Team $team, ?Carbon $expiresAt): Person
|
||||||
Notification::assertSentToTimes($this->team, ApiTokenExpiringNotification::class, 1);
|
Notification::assertSentToTimes($this->team, ApiTokenExpiringNotification::class, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('database marker prevents duplicate warnings after cache is flushed', function () {
|
||||||
|
createTokenExpiring($this->user, $this->team, now()->addHours(12));
|
||||||
|
|
||||||
|
(new ApiTokenExpirationWarningJob)->handle();
|
||||||
|
|
||||||
|
Cache::flush();
|
||||||
|
|
||||||
|
(new ApiTokenExpirationWarningJob)->handle();
|
||||||
|
|
||||||
|
Notification::assertSentToTimes($this->team, ApiTokenExpiringNotification::class, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skips tokens that already have an expiration warning marker', function () {
|
||||||
|
createTokenExpiring($this->user, $this->team, now()->addHours(12), now()->subHour());
|
||||||
|
|
||||||
|
(new ApiTokenExpirationWarningJob)->handle();
|
||||||
|
|
||||||
|
Notification::assertNothingSent();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('notifies once for each unmarked expiring token', function () {
|
||||||
|
createTokenExpiring($this->user, $this->team, now()->addHours(12));
|
||||||
|
createTokenExpiring($this->user, $this->team, now()->addHours(23));
|
||||||
|
|
||||||
|
(new ApiTokenExpirationWarningJob)->handle();
|
||||||
|
|
||||||
|
Notification::assertSentToTimes($this->team, ApiTokenExpiringNotification::class, 2);
|
||||||
|
});
|
||||||
|
|
||||||
test('skips tokens expiring more than 24h out', function () {
|
test('skips tokens expiring more than 24h out', function () {
|
||||||
createTokenExpiring($this->user, $this->team, now()->addDays(3));
|
createTokenExpiring($this->user, $this->team, now()->addDays(3));
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue