Add email notifications for expiring api tokens

This commit is contained in:
Constantin Graf
2025-07-17 15:33:06 +02:00
committed by Constantin Graf
parent 3d58f570bd
commit f9c0d64f82
18 changed files with 618 additions and 16 deletions
@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Auth;
use App\Mail\AuthApiTokenExpirationReminderMail;
use App\Mail\AuthApiTokenExpiredMail;
use App\Models\Passport\Token;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Mail;
class AuthSendReminderForExpiringApiTokensCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'auth:send-mails-expiring-api-tokens '.
' { --dry-run : Do not actually send emails or save anything to the database, just output what would happen }';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Sends emails about expiring API tokens, one week before and when they expired.';
/**
* Execute the console command.
*/
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
if ($dryRun) {
$this->comment('Running in dry-run mode. No emails will be sent and nothing will be saved to the database.');
}
$this->comment('Sending reminder emails about expiring API tokens...');
$sentMails = 0;
Token::query()
->where('expires_at', '<=', Carbon::now()->addDays(7))
->whereNull('reminder_sent_at')
->with([
'client',
'user',
])
->whereHas('user', function (Builder $query): void {
/** @var Builder<User> $query */
$query->where('is_placeholder', '=', false);
})
->isApiToken(true)
->orderBy('created_at', 'asc')
->chunk(500, function (Collection $tokens) use ($dryRun, &$sentMails): void {
/** @var Collection<int, Token> $tokens */
foreach ($tokens as $token) {
$user = $token->user;
$this->info('Start sending email to user "'.$user->email.'" ('.$user->getKey().') reminding about API token '.$token->getKey());
$sentMails++;
if (! $dryRun) {
Mail::to($user->email)
->queue(new AuthApiTokenExpirationReminderMail($token, $user));
$token->reminder_sent_at = Carbon::now();
$token->save();
}
}
});
$this->comment('Finished sending '.$sentMails.' expiring API token emails...');
$this->comment('Sent emails about expired API tokens');
$sentMails = 0;
Token::query()
->where('expires_at', '<=', Carbon::now())
->whereNull('expired_info_sent_at')
->with([
'client',
'user',
])
->whereHas('user', function (Builder $query): void {
/** @var Builder<User> $query */
$query->where('is_placeholder', '=', false);
})
->isApiToken(true)
->orderBy('created_at', 'asc')
->chunk(500, function (Collection $tokens) use ($dryRun, &$sentMails): void {
/** @var Collection<int, Token> $tokens */
foreach ($tokens as $token) {
$user = $token->user;
$this->info('Start sending email to user "'.$user->email.'" ('.$user->getKey().') about expired API token '.$token->getKey());
$sentMails++;
if (! $dryRun) {
Mail::to($user->email)
->queue(new AuthApiTokenExpiredMail($token, $user));
$token->expired_info_sent_at = Carbon::now();
$token->save();
}
}
});
$this->comment('Finished sending '.$sentMails.' expired API token emails...');
return self::SUCCESS;
}
}
+4
View File
@@ -18,6 +18,10 @@ class Kernel extends ConsoleKernel
->when(fn (): bool => config('scheduling.tasks.time_entry_send_still_running_mails'))
->everyTenMinutes();
$schedule->command('auth:send-mails-expiring-api-tokens')
->when(fn (): bool => config('scheduling.tasks.auth_send_mails_expiring_api_tokens'))
->everyTenMinutes();
$schedule->command('self-host:check-for-update')
->when(fn (): bool => config('scheduling.tasks.self_hosting_check_for_update'))
->twiceDaily();
+7 -4
View File
@@ -15,6 +15,7 @@ use Filament\Resources\Resource;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\BulkAction;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
@@ -75,7 +76,8 @@ class FailedJobResource extends Resource
->filters([])
->bulkActions([
BulkAction::make('retry')
->label('Retry')
->icon('heroicon-o-arrow-path')
->label('Retry selected')
->requiresConfirmation()
->action(function (Collection $records): void {
/** @var FailedJob $record */
@@ -87,11 +89,13 @@ class FailedJobResource extends Resource
->success()
->send();
}),
DeleteBulkAction::make(),
])
->actions([
DeleteAction::make('Delete'),
ViewAction::make('View'),
DeleteAction::make(),
ViewAction::make(),
Action::make('retry')
->icon('heroicon-o-arrow-path')
->label('Retry')
->requiresConfirmation()
->action(function (FailedJob $record): void {
@@ -109,7 +113,6 @@ class FailedJobResource extends Resource
return [
'index' => ListFailedJobs::route('/'),
'view' => ViewFailedJobs::route('/{record}'),
];
}
}
@@ -6,8 +6,8 @@ namespace App\Filament\Resources\FailedJobResource\Pages;
use App\Filament\Resources\FailedJobResource;
use App\Models\FailedJob;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Pages\Actions\Action;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Support\Facades\Artisan;
@@ -19,7 +19,8 @@ class ListFailedJobs extends ListRecords
{
return [
Action::make('retry_all')
->label('Retry all failed Jobs')
->icon('heroicon-o-arrow-path')
->label('Retry all')
->requiresConfirmation()
->action(function (): void {
Artisan::call('queue:retry all');
@@ -30,7 +31,8 @@ class ListFailedJobs extends ListRecords
}),
Action::make('delete_all')
->label('Delete all failed Jobs')
->icon('heroicon-o-trash')
->label('Delete all')
->requiresConfirmation()
->color('danger')
->action(function (): void {
+2 -9
View File
@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\TokenResource\Pages;
use App\Models\Passport\Client;
use App\Models\Passport\Token;
use Filament\Forms;
use Filament\Forms\Form;
@@ -106,17 +105,11 @@ class TokenResource extends Resource
->queries(
true: function (Builder $query) {
/** @var Builder<Token> $query */
return $query->whereHas('client', function (Builder $query) {
/** @var Builder<Client> $query */
return $query->whereJsonContains('grant_types', 'personal_access');
});
return $query->isApiToken();
},
false: function (Builder $query) {
/** @var Builder<Token> $query */
return $query->whereHas('client', function (Builder $query) {
/** @var Builder<Client> $query */
return $query->whereJsonDoesntContain('grant_types', 'personal_access');
});
return $query->isApiToken(false);
},
blank: function (Builder $query) {
/** @var Builder<Token> $query */
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use App\Models\Passport\Token;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\URL;
class AuthApiTokenExpirationReminderMail extends Mailable
{
use Queueable, SerializesModels;
public Token $token;
public User $user;
/**
* Create a new message instance.
*
* @return void
*/
public function __construct(Token $token, User $user)
{
$this->token = $token;
$this->user = $user;
}
/**
* Build the message.
*/
public function build(): self
{
return $this->markdown('emails.auth-api-expiration-reminder', [
'profileUrl' => URL::to('user/profile'),
'tokenName' => $this->token->name,
])
->subject(__('Your API token will expire in 7 days!'));
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use App\Models\Passport\Token;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\URL;
class AuthApiTokenExpiredMail extends Mailable
{
use Queueable, SerializesModels;
public Token $token;
public User $user;
/**
* Create a new message instance.
*
* @return void
*/
public function __construct(Token $token, User $user)
{
$this->token = $token;
$this->user = $user;
}
/**
* Build the message.
*/
public function build(): self
{
return $this->markdown('emails.auth-api-token-expired', [
'profileUrl' => URL::to('user/profile'),
'tokenName' => $this->token->name,
])
->subject(__('Your API token has expired!'));
}
}
+41
View File
@@ -6,6 +6,7 @@ namespace App\Models\Passport;
use App\Models\User;
use Database\Factories\Passport\TokenFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
@@ -18,11 +19,15 @@ use Laravel\Passport\Token as PassportToken;
* @property null|string $name
* @property array<string> $scopes
* @property bool $revoked
* @property Carbon|null $reminder_sent_at
* @property Carbon|null $expired_info_sent_at
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property Carbon|null $expires_at
* @property-read Client|null $client
* @property-read User|null $user
*
* @method Builder<Token> isApiToken(bool $isApiToken = true)
*/
class Token extends PassportToken
{
@@ -52,4 +57,40 @@ class Token extends PassportToken
{
return $this->belongsTo(User::class, 'user_id');
}
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'scopes' => 'array',
'revoked' => 'bool',
'expires_at' => 'datetime',
'reminder_sent_at' => 'datetime',
'expired_info_sent_at' => 'datetime',
];
}
/**
* @param Builder<static> $query
* @return Builder<static>
*/
public function scopeIsApiToken(Builder $query, bool $isApiToken = true): Builder
{
if ($isApiToken) {
return $query->whereHas('client', function (Builder $query): void {
/** @var Builder<Client> $query */
$query->whereJsonContains('grant_types', 'personal_access');
});
} else {
return $query->whereHas('client', function (Builder $query): void {
/** @var Builder<Client> $query */
$query->whereJsonDoesntContain('grant_types', 'personal_access');
});
}
}
}
+1
View File
@@ -6,6 +6,7 @@ return [
'tasks' => [
'time_entry_send_still_running_mails' => (bool) env('SCHEDULING_TASK_TIME_ENTRY_SEND_STILL_RUNNING_MAILS', true),
'auth_send_mails_expiring_api_tokens' => (bool) env('SCHEDULING_TASK_AUTH_SEND_MAILS_EXPIRING_API_TOKENS', true),
'self_hosting_check_for_update' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_CHECK_FOR_UPDATE', true),
'self_hosting_telemetry' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_TELEMETRY', true),
'self_hosting_database_consistency' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_DATABASE_CONSISTENCY', false),
@@ -36,6 +36,22 @@ class ClientFactory extends BaseClientFactory
];
}
public function desktopClient(): self
{
return $this->state(fn (array $attributes) => [
'name' => 'Desktop',
'grant_types' => ['urn:ietf:params:oauth:grant-type:device_code', 'refresh_token', 'authorization_code', 'implicit'],
]);
}
public function apiClient(): self
{
return $this->state(fn (array $attributes) => [
'name' => 'API',
'grant_types' => ['urn:ietf:params:oauth:grant-type:device_code', 'refresh_token', 'client_credentials', 'personal_access'],
]);
}
public function personalAccessClient(): self
{
return $this->state(function (array $attributes) {
@@ -31,6 +31,8 @@ class TokenFactory extends Factory
'created_at' => $this->faker->dateTime,
'updated_at' => $this->faker->dateTime,
'expires_at' => $this->faker->dateTime,
'reminder_sent_at' => null,
'expired_info_sent_at' => null,
];
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
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('oauth_access_tokens', function (Blueprint $table): void {
$table->dateTime('reminder_sent_at')->nullable();
$table->dateTime('expired_info_sent_at')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('oauth_access_tokens', function (Blueprint $table): void {
$table->dropColumn('reminder_sent_at');
$table->dropColumn('expired_info_sent_at');
});
}
};
@@ -0,0 +1,12 @@
@component('mail::message')
{{ __('The API token ":token" expired.', ['token' => $tokenName]) }}
{{ __('You can create a new API token in your profile:') }}
@component('mail::button', ['url' => $profileUrl])
{{ __('Go to your profile') }}
@endcomponent
@endcomponent
@@ -0,0 +1,13 @@
@component('mail::message')
{{ __('The API token ":token" will expire in 7 days!', ['token' => $tokenName]) }}
{{ __('Please make sure to create a new API token and use the new one instead before it expires to avoid any disruptions in service.') }}
{{ __('You can create a new API token in your profile:') }}
@component('mail::button', ['url' => $profileUrl])
{{ __('Go to your profile') }}
@endcomponent
@endcomponent
@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Console\Commands\Auth;
use App\Console\Commands\Auth\AuthSendReminderForExpiringApiTokensCommand;
use App\Mail\AuthApiTokenExpirationReminderMail;
use App\Mail\AuthApiTokenExpiredMail;
use App\Models\Passport\Client;
use App\Models\Passport\Token;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Mail;
use PHPUnit\Framework\Attributes\CoversClass;
use Tests\TestCaseWithDatabase;
#[CoversClass(AuthSendReminderForExpiringApiTokensCommand::class)]
class AuthSendReminderForExpiringApiTokensCommandTest extends TestCaseWithDatabase
{
public function test_sends_mail_for_expired_api_tokens_but_ignores_the_one_where_the_mail_was_already_sent_and_ignores_non_api_tokens(): void
{
// Arrange
$user = $this->createUserWithPermission();
$apiClient = Client::factory()->apiClient()->create();
$otherClient = Client::factory()->desktopClient()->create();
$expiredToken = Token::factory()->forUser($user->user)->forClient($apiClient)->create([
'reminder_sent_at' => Carbon::now()->subDays(8),
'expired_info_sent_at' => null,
'expires_at' => Carbon::now()->subDay(),
]);
$expiredTokenWithMailSent = Token::factory()->forUser($user->user)->forClient($apiClient)->create([
'reminder_sent_at' => Carbon::now()->subDays(8),
'expired_info_sent_at' => Carbon::now(),
'expires_at' => Carbon::now()->subDay(),
]);
$nonApiToken = Token::factory()->forUser($user->user)->forClient($otherClient)->create([
'reminder_sent_at' => null,
'expired_info_sent_at' => null,
'expires_at' => Carbon::now()->subDay(),
]);
// Act
$exitCode = $this->withoutMockingConsoleOutput()->artisan('auth:send-mails-expiring-api-tokens');
// Assert
$this->assertSame(Command::SUCCESS, $exitCode);
$expiredToken->refresh();
$expiredTokenWithMailSent->refresh();
$nonApiToken->refresh();
$this->assertNotNull($expiredToken->expired_info_sent_at);
$this->assertNotNull($expiredTokenWithMailSent->expired_info_sent_at);
$this->assertNull($nonApiToken->reminder_sent_at);
$this->assertNull($nonApiToken->expired_info_sent_at);
Mail::assertNotQueued(AuthApiTokenExpirationReminderMail::class);
Mail::assertQueued(AuthApiTokenExpiredMail::class, function (AuthApiTokenExpiredMail $mail) use ($user, $expiredToken): bool {
return $mail->hasTo($user->user->email) &&
$mail->token->is($expiredToken) &&
$mail->user->is($user->user);
});
$output = Artisan::output();
$this->assertStringContainsString('Finished sending 0 expiring API token emails...', $output);
$this->assertStringContainsString('Finished sending 1 expired API token emails...', $output);
$this->assertStringContainsString(
'Start sending email to user "'.$user->user->email.'" ('.
$user->user->id.') about expired API token '.$expiredToken->getKey(), $output);
}
public function test_sends_mail_for_api_tokens_that_expire_soon_but_ignores_the_one_where_the_mail_was_already_sent_and_ignores_non_api_tokens(): void
{
// Arrange
$user = $this->createUserWithPermission();
$apiClient = Client::factory()->apiClient()->create();
$otherClient = Client::factory()->desktopClient()->create();
$expiringToken = Token::factory()->forUser($user->user)->forClient($apiClient)->create([
'reminder_sent_at' => null,
'expired_info_sent_at' => null,
'expires_at' => Carbon::now()->addDays(6),
]);
$expiringTokenWithMailSent = Token::factory()->forUser($user->user)->forClient($apiClient)->create([
'reminder_sent_at' => Carbon::now(),
'expired_info_sent_at' => null,
'expires_at' => Carbon::now()->addDays(6),
]);
$nonApiToken = Token::factory()->forUser($user->user)->forClient($otherClient)->create([
'reminder_sent_at' => null,
'expired_info_sent_at' => null,
'expires_at' => Carbon::now()->addDays(6),
]);
// Act
$exitCode = $this->withoutMockingConsoleOutput()->artisan('auth:send-mails-expiring-api-tokens');
// Assert
$this->assertSame(Command::SUCCESS, $exitCode);
$expiringToken->refresh();
$expiringTokenWithMailSent->refresh();
$nonApiToken->refresh();
$this->assertNotNull($expiringToken->reminder_sent_at);
$this->assertNull($expiringToken->expired_info_sent_at);
$this->assertNotNull($expiringTokenWithMailSent->reminder_sent_at);
$this->assertNull($expiringTokenWithMailSent->expired_info_sent_at);
$this->assertNull($nonApiToken->reminder_sent_at);
$this->assertNull($nonApiToken->expired_info_sent_at);
Mail::assertNotQueued(AuthApiTokenExpiredMail::class);
Mail::assertQueued(AuthApiTokenExpirationReminderMail::class, function (AuthApiTokenExpirationReminderMail $mail) use ($user, $expiringToken): bool {
return $mail->hasTo($user->user->email) &&
$mail->token->is($expiringToken) &&
$mail->user->is($user->user);
});
$output = Artisan::output();
$this->assertStringContainsString('Finished sending 1 expiring API token emails...', $output);
$this->assertStringContainsString('Finished sending 0 expired API token emails...', $output);
$this->assertStringContainsString(
'Start sending email to user "'.$user->user->email.'" ('.
$user->user->id.') reminding about API token '.$expiringToken->getKey(), $output);
}
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Mail;
use App\Mail\AuthApiTokenExpirationReminderMail;
use App\Models\Passport\Client;
use App\Models\Passport\Token;
use App\Models\User;
use PHPUnit\Framework\Attributes\CoversClass;
use Tests\TestCaseWithDatabase;
#[CoversClass(AuthApiTokenExpirationReminderMail::class)]
class AuthApiTokenExpirationReminderMailTest extends TestCaseWithDatabase
{
public function test_mail_renders_content_correctly(): void
{
// Arrange
$user = User::factory()->create();
$client = Client::factory()->apiClient()->create();
$token = Token::factory()->forClient($client)->forUser($user)->create([
'name' => 'TEST',
]);
$mail = new AuthApiTokenExpirationReminderMail($token, $user);
// Act
$rendered = $mail->render();
// Assert
$this->assertStringContainsString('The API token "TEST" expired.', $rendered);
}
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Mail;
use App\Mail\AuthApiTokenExpiredMail;
use App\Models\Passport\Client;
use App\Models\Passport\Token;
use App\Models\User;
use PHPUnit\Framework\Attributes\CoversClass;
use Tests\TestCaseWithDatabase;
#[CoversClass(AuthApiTokenExpiredMail::class)]
class AuthApiTokenExpiredMailTest extends TestCaseWithDatabase
{
public function test_mail_renders_content_correctly(): void
{
// Arrange
$user = User::factory()->create();
$client = Client::factory()->apiClient()->create();
$token = Token::factory()->forClient($client)->forUser($user)->create([
'name' => 'TEST',
]);
$mail = new AuthApiTokenExpiredMail($token, $user);
// Act
$rendered = $mail->render();
// Assert
$this->assertStringContainsString('The API token "TEST" will expire in 7 days!', $rendered);
}
}
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Model\Passport;
use App\Models\Passport\Client;
use App\Models\Passport\Token;
use App\Models\User;
use PHPUnit\Framework\Attributes\CoversClass;
use Tests\Unit\Model\ModelTestAbstract;
#[CoversClass(Token::class)]
class TokenModelTest extends ModelTestAbstract
{
public function test_it_belongs_to_a_client(): void
{
// Arrange
$client = Client::factory()->create();
$token = Token::factory()->forClient($client)->create();
// Act
$token->refresh();
$clientRel = $token->client;
// Assert
$this->assertNotNull($clientRel);
$this->assertTrue($clientRel->is($client));
}
public function test_it_belongs_to_a_user(): void
{
// Arrange
$user = User::factory()->create();
$client = Client::factory()->create();
$token = Token::factory()->forUser($user)->forClient($client)->create();
// Act
$token->refresh();
$userRel = $token->user;
// Assert
$this->assertNotNull($userRel);
$this->assertTrue($userRel->is($user));
}
public function test_scope_is_api_tokens_only_returns_api_tokens_with_no_parameters(): void
{
// Arrange
$clientApi = Client::factory()->apiClient()->create();
$clientDesktop = Client::factory()->desktopClient()->create();
$token1 = Token::factory()->forClient($clientApi)->create();
$token2 = Token::factory()->forClient($clientDesktop)->create();
// Act
$apiTokens = Token::query()
->isApiToken()
->get();
// Assert
$this->assertCount(1, $apiTokens);
$this->assertTrue($apiTokens->first()->is($token1));
}
public function test_scope_is_api_tokens_only_returns_api_tokens_with_true(): void
{
// Arrange
$clientApi = Client::factory()->apiClient()->create();
$clientDesktop = Client::factory()->desktopClient()->create();
$token1 = Token::factory()->forClient($clientApi)->create();
$token2 = Token::factory()->forClient($clientDesktop)->create();
// Act
$apiTokens = Token::query()
->isApiToken(true)
->get();
// Assert
$this->assertCount(1, $apiTokens);
$this->assertTrue($apiTokens->first()->is($token1));
}
public function test_scope_is_api_tokens_only_returns_api_tokens_with_false(): void
{
// Arrange
$clientApi = Client::factory()->apiClient()->create();
$clientDesktop = Client::factory()->desktopClient()->create();
$token1 = Token::factory()->forClient($clientApi)->create();
$token2 = Token::factory()->forClient($clientDesktop)->create();
// Act
$apiTokens = Token::query()
->isApiToken(false)
->get();
// Assert
$this->assertCount(1, $apiTokens);
$this->assertTrue($apiTokens->first()->is($token2));
}
}