Skip to content

Commit f9c0d64

Browse files
committed
Add email notifications for expiring api tokens
1 parent 3d58f57 commit f9c0d64

18 files changed

+618
-16
lines changed
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Console\Commands\Auth;
6+
7+
use App\Mail\AuthApiTokenExpirationReminderMail;
8+
use App\Mail\AuthApiTokenExpiredMail;
9+
use App\Models\Passport\Token;
10+
use App\Models\User;
11+
use Illuminate\Console\Command;
12+
use Illuminate\Database\Eloquent\Builder;
13+
use Illuminate\Database\Eloquent\Collection;
14+
use Illuminate\Support\Carbon;
15+
use Illuminate\Support\Facades\Mail;
16+
17+
class AuthSendReminderForExpiringApiTokensCommand extends Command
18+
{
19+
/**
20+
* The name and signature of the console command.
21+
*
22+
* @var string
23+
*/
24+
protected $signature = 'auth:send-mails-expiring-api-tokens '.
25+
' { --dry-run : Do not actually send emails or save anything to the database, just output what would happen }';
26+
27+
/**
28+
* The console command description.
29+
*
30+
* @var string
31+
*/
32+
protected $description = 'Sends emails about expiring API tokens, one week before and when they expired.';
33+
34+
/**
35+
* Execute the console command.
36+
*/
37+
public function handle(): int
38+
{
39+
$dryRun = (bool) $this->option('dry-run');
40+
if ($dryRun) {
41+
$this->comment('Running in dry-run mode. No emails will be sent and nothing will be saved to the database.');
42+
}
43+
44+
$this->comment('Sending reminder emails about expiring API tokens...');
45+
$sentMails = 0;
46+
Token::query()
47+
->where('expires_at', '<=', Carbon::now()->addDays(7))
48+
->whereNull('reminder_sent_at')
49+
->with([
50+
'client',
51+
'user',
52+
])
53+
->whereHas('user', function (Builder $query): void {
54+
/** @var Builder<User> $query */
55+
$query->where('is_placeholder', '=', false);
56+
})
57+
->isApiToken(true)
58+
->orderBy('created_at', 'asc')
59+
->chunk(500, function (Collection $tokens) use ($dryRun, &$sentMails): void {
60+
/** @var Collection<int, Token> $tokens */
61+
foreach ($tokens as $token) {
62+
$user = $token->user;
63+
$this->info('Start sending email to user "'.$user->email.'" ('.$user->getKey().') reminding about API token '.$token->getKey());
64+
$sentMails++;
65+
if (! $dryRun) {
66+
Mail::to($user->email)
67+
->queue(new AuthApiTokenExpirationReminderMail($token, $user));
68+
$token->reminder_sent_at = Carbon::now();
69+
$token->save();
70+
}
71+
}
72+
});
73+
$this->comment('Finished sending '.$sentMails.' expiring API token emails...');
74+
75+
$this->comment('Sent emails about expired API tokens');
76+
$sentMails = 0;
77+
Token::query()
78+
->where('expires_at', '<=', Carbon::now())
79+
->whereNull('expired_info_sent_at')
80+
->with([
81+
'client',
82+
'user',
83+
])
84+
->whereHas('user', function (Builder $query): void {
85+
/** @var Builder<User> $query */
86+
$query->where('is_placeholder', '=', false);
87+
})
88+
->isApiToken(true)
89+
->orderBy('created_at', 'asc')
90+
->chunk(500, function (Collection $tokens) use ($dryRun, &$sentMails): void {
91+
/** @var Collection<int, Token> $tokens */
92+
foreach ($tokens as $token) {
93+
$user = $token->user;
94+
$this->info('Start sending email to user "'.$user->email.'" ('.$user->getKey().') about expired API token '.$token->getKey());
95+
$sentMails++;
96+
if (! $dryRun) {
97+
Mail::to($user->email)
98+
->queue(new AuthApiTokenExpiredMail($token, $user));
99+
$token->expired_info_sent_at = Carbon::now();
100+
$token->save();
101+
}
102+
}
103+
});
104+
$this->comment('Finished sending '.$sentMails.' expired API token emails...');
105+
106+
return self::SUCCESS;
107+
}
108+
}

app/Console/Kernel.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ protected function schedule(Schedule $schedule): void
1818
->when(fn (): bool => config('scheduling.tasks.time_entry_send_still_running_mails'))
1919
->everyTenMinutes();
2020

21+
$schedule->command('auth:send-mails-expiring-api-tokens')
22+
->when(fn (): bool => config('scheduling.tasks.auth_send_mails_expiring_api_tokens'))
23+
->everyTenMinutes();
24+
2125
$schedule->command('self-host:check-for-update')
2226
->when(fn (): bool => config('scheduling.tasks.self_hosting_check_for_update'))
2327
->twiceDaily();

app/Filament/Resources/FailedJobResource.php

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use Filament\Tables\Actions\Action;
1616
use Filament\Tables\Actions\BulkAction;
1717
use Filament\Tables\Actions\DeleteAction;
18+
use Filament\Tables\Actions\DeleteBulkAction;
1819
use Filament\Tables\Actions\ViewAction;
1920
use Filament\Tables\Columns\TextColumn;
2021
use Filament\Tables\Table;
@@ -75,7 +76,8 @@ public static function table(Table $table): Table
7576
->filters([])
7677
->bulkActions([
7778
BulkAction::make('retry')
78-
->label('Retry')
79+
->icon('heroicon-o-arrow-path')
80+
->label('Retry selected')
7981
->requiresConfirmation()
8082
->action(function (Collection $records): void {
8183
/** @var FailedJob $record */
@@ -87,11 +89,13 @@ public static function table(Table $table): Table
8789
->success()
8890
->send();
8991
}),
92+
DeleteBulkAction::make(),
9093
])
9194
->actions([
92-
DeleteAction::make('Delete'),
93-
ViewAction::make('View'),
95+
DeleteAction::make(),
96+
ViewAction::make(),
9497
Action::make('retry')
98+
->icon('heroicon-o-arrow-path')
9599
->label('Retry')
96100
->requiresConfirmation()
97101
->action(function (FailedJob $record): void {
@@ -109,7 +113,6 @@ public static function getPages(): array
109113
return [
110114
'index' => ListFailedJobs::route('/'),
111115
'view' => ViewFailedJobs::route('/{record}'),
112-
113116
];
114117
}
115118
}

app/Filament/Resources/FailedJobResource/Pages/ListFailedJobs.php

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66

77
use App\Filament\Resources\FailedJobResource;
88
use App\Models\FailedJob;
9+
use Filament\Actions\Action;
910
use Filament\Notifications\Notification;
10-
use Filament\Pages\Actions\Action;
1111
use Filament\Resources\Pages\ListRecords;
1212
use Illuminate\Support\Facades\Artisan;
1313

@@ -19,7 +19,8 @@ public function getHeaderActions(): array
1919
{
2020
return [
2121
Action::make('retry_all')
22-
->label('Retry all failed Jobs')
22+
->icon('heroicon-o-arrow-path')
23+
->label('Retry all')
2324
->requiresConfirmation()
2425
->action(function (): void {
2526
Artisan::call('queue:retry all');
@@ -30,7 +31,8 @@ public function getHeaderActions(): array
3031
}),
3132

3233
Action::make('delete_all')
33-
->label('Delete all failed Jobs')
34+
->icon('heroicon-o-trash')
35+
->label('Delete all')
3436
->requiresConfirmation()
3537
->color('danger')
3638
->action(function (): void {

app/Filament/Resources/TokenResource.php

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
namespace App\Filament\Resources;
66

77
use App\Filament\Resources\TokenResource\Pages;
8-
use App\Models\Passport\Client;
98
use App\Models\Passport\Token;
109
use Filament\Forms;
1110
use Filament\Forms\Form;
@@ -106,17 +105,11 @@ public static function table(Table $table): Table
106105
->queries(
107106
true: function (Builder $query) {
108107
/** @var Builder<Token> $query */
109-
return $query->whereHas('client', function (Builder $query) {
110-
/** @var Builder<Client> $query */
111-
return $query->whereJsonContains('grant_types', 'personal_access');
112-
});
108+
return $query->isApiToken();
113109
},
114110
false: function (Builder $query) {
115111
/** @var Builder<Token> $query */
116-
return $query->whereHas('client', function (Builder $query) {
117-
/** @var Builder<Client> $query */
118-
return $query->whereJsonDoesntContain('grant_types', 'personal_access');
119-
});
112+
return $query->isApiToken(false);
120113
},
121114
blank: function (Builder $query) {
122115
/** @var Builder<Token> $query */
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Mail;
6+
7+
use App\Models\Passport\Token;
8+
use App\Models\User;
9+
use Illuminate\Bus\Queueable;
10+
use Illuminate\Mail\Mailable;
11+
use Illuminate\Queue\SerializesModels;
12+
use Illuminate\Support\Facades\URL;
13+
14+
class AuthApiTokenExpirationReminderMail extends Mailable
15+
{
16+
use Queueable, SerializesModels;
17+
18+
public Token $token;
19+
20+
public User $user;
21+
22+
/**
23+
* Create a new message instance.
24+
*
25+
* @return void
26+
*/
27+
public function __construct(Token $token, User $user)
28+
{
29+
$this->token = $token;
30+
$this->user = $user;
31+
}
32+
33+
/**
34+
* Build the message.
35+
*/
36+
public function build(): self
37+
{
38+
return $this->markdown('emails.auth-api-expiration-reminder', [
39+
'profileUrl' => URL::to('user/profile'),
40+
'tokenName' => $this->token->name,
41+
])
42+
->subject(__('Your API token will expire in 7 days!'));
43+
}
44+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Mail;
6+
7+
use App\Models\Passport\Token;
8+
use App\Models\User;
9+
use Illuminate\Bus\Queueable;
10+
use Illuminate\Mail\Mailable;
11+
use Illuminate\Queue\SerializesModels;
12+
use Illuminate\Support\Facades\URL;
13+
14+
class AuthApiTokenExpiredMail extends Mailable
15+
{
16+
use Queueable, SerializesModels;
17+
18+
public Token $token;
19+
20+
public User $user;
21+
22+
/**
23+
* Create a new message instance.
24+
*
25+
* @return void
26+
*/
27+
public function __construct(Token $token, User $user)
28+
{
29+
$this->token = $token;
30+
$this->user = $user;
31+
}
32+
33+
/**
34+
* Build the message.
35+
*/
36+
public function build(): self
37+
{
38+
return $this->markdown('emails.auth-api-token-expired', [
39+
'profileUrl' => URL::to('user/profile'),
40+
'tokenName' => $this->token->name,
41+
])
42+
->subject(__('Your API token has expired!'));
43+
}
44+
}

app/Models/Passport/Token.php

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use App\Models\User;
88
use Database\Factories\Passport\TokenFactory;
9+
use Illuminate\Database\Eloquent\Builder;
910
use Illuminate\Database\Eloquent\Factories\HasFactory;
1011
use Illuminate\Database\Eloquent\Relations\BelongsTo;
1112
use Illuminate\Support\Carbon;
@@ -18,11 +19,15 @@
1819
* @property null|string $name
1920
* @property array<string> $scopes
2021
* @property bool $revoked
22+
* @property Carbon|null $reminder_sent_at
23+
* @property Carbon|null $expired_info_sent_at
2124
* @property Carbon|null $created_at
2225
* @property Carbon|null $updated_at
2326
* @property Carbon|null $expires_at
2427
* @property-read Client|null $client
2528
* @property-read User|null $user
29+
*
30+
* @method Builder<Token> isApiToken(bool $isApiToken = true)
2631
*/
2732
class Token extends PassportToken
2833
{
@@ -52,4 +57,40 @@ public function user(): BelongsTo
5257
{
5358
return $this->belongsTo(User::class, 'user_id');
5459
}
60+
61+
/**
62+
* Get the attributes that should be cast.
63+
*
64+
* @return array<string, string>
65+
*/
66+
protected function casts(): array
67+
{
68+
return [
69+
'scopes' => 'array',
70+
'revoked' => 'bool',
71+
'expires_at' => 'datetime',
72+
'reminder_sent_at' => 'datetime',
73+
'expired_info_sent_at' => 'datetime',
74+
];
75+
}
76+
77+
/**
78+
* @param Builder<static> $query
79+
* @return Builder<static>
80+
*/
81+
public function scopeIsApiToken(Builder $query, bool $isApiToken = true): Builder
82+
{
83+
if ($isApiToken) {
84+
return $query->whereHas('client', function (Builder $query): void {
85+
/** @var Builder<Client> $query */
86+
$query->whereJsonContains('grant_types', 'personal_access');
87+
});
88+
} else {
89+
return $query->whereHas('client', function (Builder $query): void {
90+
/** @var Builder<Client> $query */
91+
$query->whereJsonDoesntContain('grant_types', 'personal_access');
92+
});
93+
}
94+
95+
}
5596
}

config/scheduling.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
'tasks' => [
88
'time_entry_send_still_running_mails' => (bool) env('SCHEDULING_TASK_TIME_ENTRY_SEND_STILL_RUNNING_MAILS', true),
9+
'auth_send_mails_expiring_api_tokens' => (bool) env('SCHEDULING_TASK_AUTH_SEND_MAILS_EXPIRING_API_TOKENS', true),
910
'self_hosting_check_for_update' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_CHECK_FOR_UPDATE', true),
1011
'self_hosting_telemetry' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_TELEMETRY', true),
1112
'self_hosting_database_consistency' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_DATABASE_CONSISTENCY', false),

0 commit comments

Comments
 (0)