fix(api-tokens): persist expiration warning state (#10184)

This commit is contained in:
Andras Bacsai 2026-05-13 10:43:50 +02:00 committed by GitHub
commit 76c8defef3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 112 additions and 12 deletions

View file

@ -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]);
} }
}); });
} }

View file

@ -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',
];
}
} }

View file

@ -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');
});
}
};

View file

@ -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));