Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

use Spatie\LaravelSettings\Migrations\SettingsMigration;

return new class extends SettingsMigration
{
public function up(): void
{
$this->migrator->add('auth.login_notification_enabled', false);
}
};
19 changes: 19 additions & 0 deletions lang/en/auth.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,23 @@
'verification-link-sent' => 'A fresh verification link has been sent to your email address.',
'magic-link-sent' => "If an account with that email exists, we've sent a magic login link.",
'magic-link-expired' => 'This magic link has expired or has already been used.',
'notifications' => [
'title' => 'Notifications',
'description' => 'Configure security notifications sent to users.',
'login-enabled' => 'Send login notifications',
'login-help' => 'Email users after a successful sign-in to their account.',
],
'login-notification' => [
'subject' => 'New sign-in to your :app account',
'greeting' => 'Hello :name,',
'notice' => 'We noticed a new sign-in to your :app account.',
'app' => 'App: :app',
'time' => 'Time: :time',
'ip-address' => 'IP address: :ip',
'device-details' => 'Device details: :device',
'recognized' => 'If this was you, no action is needed.',
'action' => 'Reset your password',
'unrecognized' => "If you don't recognize this activity, reset your password immediately.",
'unknown' => 'Unknown',
],
];
19 changes: 19 additions & 0 deletions lang/pt_BR/auth.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,23 @@
'password' => 'A senha fornecida está incorreta.',
'throttle' => 'Muitas tentativas de login. Tente novamente em :seconds segundos.',
'verification-link-sent' => 'Um novo link de verificação foi enviado para seu endereço de email.',
'notifications' => [
'title' => 'Notificações',
'description' => 'Configure as notificações de segurança enviadas aos usuários.',
'login-enabled' => 'Enviar notificações de acesso',
'login-help' => 'Envie um email aos usuários após um acesso bem-sucedido à conta.',
],
'login-notification' => [
'subject' => 'Novo acesso à sua conta :app',
'greeting' => 'Olá :name,',
'notice' => 'Notamos um novo acesso à sua conta :app.',
'app' => 'Aplicativo: :app',
'time' => 'Horário: :time',
'ip-address' => 'Endereço IP: :ip',
'device-details' => 'Detalhes do dispositivo: :device',
'recognized' => 'Se foi você, nenhuma ação é necessária.',
'action' => 'Redefinir sua senha',
'unrecognized' => 'Se você não reconhece esta atividade, redefina sua senha imediatamente.',
'unknown' => 'Desconhecido',
],
];
20 changes: 20 additions & 0 deletions src/Events/ReturningUserAuthenticated.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

namespace Modules\Auth\Events;

use App\Models\User;
use Carbon\CarbonInterface;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class ReturningUserAuthenticated
{
use Dispatchable, SerializesModels;

public function __construct(
public User $user,
public CarbonInterface $loggedInAt,
public ?string $ipAddress,
public ?string $userAgent,
) {}
}
10 changes: 9 additions & 1 deletion src/Filament/Pages/AuthenticationSettings.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ class AuthenticationSettings extends SettingsPage

protected static string $settings = AuthSettings::class;


public static function getNavigationLabel(): string
{
return __('Authentication');
Expand Down Expand Up @@ -47,6 +46,15 @@ public function form(Schema $schema): Schema
->suffix(__('minutes')),
])
->columns(1),
Section::make(__('auth::auth.notifications.title'))
->description(__('auth::auth.notifications.description'))
->icon(Heroicon::OutlinedBellAlert)
->schema([
Toggle::make('login_notification_enabled')
->label(__('auth::auth.notifications.login-enabled'))
->helperText(__('auth::auth.notifications.login-help')),
])
->columns(1),
]);
}
}
12 changes: 10 additions & 2 deletions src/Http/Controllers/LoginController.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia;
use Inertia\Response;
use Modules\Auth\Events\ReturningUserAuthenticated;
use Modules\Auth\Exceptions\AuthException;
use Modules\Auth\Http\Requests\LoginRequest;

Expand Down Expand Up @@ -36,9 +37,16 @@ public function store(LoginRequest $request)
return back()->with(['error' => $e->getMessage()]);
}

Auth::login($user, request()->boolean('remember'));
Auth::login($user, $request->boolean('remember'));

request()->session()->regenerate();
$request->session()->regenerate();

ReturningUserAuthenticated::dispatch(
$user,
now(),
$request->ip(),
$request->userAgent(),
);

Toast::default(
__('auth::auth.welcome-back', ['name' => $user->name]),
Expand Down
8 changes: 8 additions & 0 deletions src/Http/Controllers/MagicLinkController.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
use Modules\Auth\Events\ReturningUserAuthenticated;
use Modules\Auth\Http\Middleware\EnsureMagicLinkEnabled;
use Modules\Auth\Models\MagicLinkToken;
use Modules\Auth\Notifications\MagicLinkNotification;
Expand Down Expand Up @@ -105,6 +106,13 @@ public function authenticate(Request $request, string $token): \Symfony\Componen

$request->session()->regenerate();

ReturningUserAuthenticated::dispatch(
$user,
now(),
$request->ip(),
$request->userAgent(),
);

Toast::default(__('auth::auth.welcome-back', ['name' => $user->name]));

$intended = $request->query('intended');
Expand Down
15 changes: 13 additions & 2 deletions src/Http/Controllers/SocialiteController.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
namespace Modules\Auth\Http\Controllers;

use App\Helpers\Toast;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Validator;
use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\Two\User;
use Modules\Auth\Events\ReturningUserAuthenticated;
use Modules\Auth\Exceptions\SocialiteException;
use Modules\Auth\Services\SocialiteService;
use Symfony\Component\HttpFoundation\Response as RedirectResponse;
Expand All @@ -25,7 +27,7 @@ public function redirect(string $provider): RedirectResponse
return Socialite::driver($provider)->redirect();
}

public function callback(string $provider): RedirectResponse
public function callback(Request $request, string $provider): RedirectResponse
{
$validator = Validator::make(['provider' => $provider], [
'provider' => 'required|string',
Expand Down Expand Up @@ -57,7 +59,16 @@ public function callback(string $provider): RedirectResponse

Auth::login($user);

request()->session()->regenerate();
$request->session()->regenerate();

if (! $user->wasRecentlyCreated) {
ReturningUserAuthenticated::dispatch(
$user,
now(),
$request->ip(),
$request->userAgent(),
);
}

Toast::default(
__($user->wasRecentlyCreated ? 'auth::auth.welcome' : 'auth::auth.welcome-back', [
Expand Down
27 changes: 27 additions & 0 deletions src/Listeners/SendLoginNotification.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

namespace Modules\Auth\Listeners;

use Modules\Auth\Events\ReturningUserAuthenticated;
use Modules\Auth\Notifications\LoginNotification;
use Modules\Auth\Settings\AuthSettings;

class SendLoginNotification
{
public function __construct(
private AuthSettings $settings,
) {}

public function handle(ReturningUserAuthenticated $event): void
{
if (! $this->settings->login_notification_enabled) {
return;
}

$event->user->notify(new LoginNotification(
loggedInAt: $event->loggedInAt,
ipAddress: $event->ipAddress,
userAgent: $event->userAgent,
));
}
}
81 changes: 81 additions & 0 deletions src/Notifications/LoginNotification.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php

namespace Modules\Auth\Notifications;

use Carbon\CarbonInterface;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Str;

class LoginNotification extends Notification implements ShouldQueue
{
use Queueable;

public ?string $userAgent;

public function __construct(
public CarbonInterface $loggedInAt,
public ?string $ipAddress,
?string $userAgent,
) {
$this->userAgent = $userAgent === null
? null
: Str::limit($userAgent, 500, '');
}

/**
* Get the notification's delivery channels.
*
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return ['mail'];
}

/**
* Get the mail representation of the notification.
*/
public function toMail(object $notifiable): MailMessage
{
$appName = (string) config('app.name');
$unknown = __('auth::auth.login-notification.unknown');
$loggedInAt = $this->loggedInAt
->copy()
->setTimezone((string) config('app.timezone', 'UTC'))
->locale(app()->getLocale())
->isoFormat('LLL Z');

return (new MailMessage)
->subject(__('auth::auth.login-notification.subject', ['app' => $appName]))
->greeting(__('auth::auth.login-notification.greeting', ['name' => $notifiable->name]))
->line(__('auth::auth.login-notification.notice', ['app' => $appName]))
->line(__('auth::auth.login-notification.app', ['app' => $appName]))
->line(__('auth::auth.login-notification.time', ['time' => $loggedInAt]))
->line(__('auth::auth.login-notification.ip-address', [
'ip' => $this->ipAddress ?? $unknown,
]))
->line(__('auth::auth.login-notification.device-details', [
'device' => $this->userAgent ?? $unknown,
]))
->line(__('auth::auth.login-notification.recognized'))
->action(__('auth::auth.login-notification.action'), route('password.request'))
->line(__('auth::auth.login-notification.unrecognized'));
}

/**
* Get the array representation of the notification.
*
* @return array<string, mixed>
*/
public function toArray(object $notifiable): array
{
return [
'logged_in_at' => $this->loggedInAt->toIso8601String(),
'ip_address' => $this->ipAddress,
'user_agent' => $this->userAgent,
];
}
}
2 changes: 2 additions & 0 deletions src/Settings/AuthSettings.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ class AuthSettings extends Settings

public int $magic_link_expiry;

public bool $login_notification_enabled;

public static function group(): string
{
return 'auth';
Expand Down
10 changes: 7 additions & 3 deletions tests/Feature/AuthenticationSettingsPageTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,11 @@ public function test_administrator_can_load_authentication_settings_form(): void
->assertOk();

Livewire::test(AuthenticationSettings::class)
->assertFormSet([
->assertFormFieldExists('login_notification_enabled')
->assertSchemaStateSet([
'magic_link_enabled' => true,
'magic_link_expiry' => 15,
'login_notification_enabled' => false,
]);
}

Expand All @@ -43,15 +45,17 @@ public function test_administrator_can_save_authentication_settings(): void
->fillForm([
'magic_link_enabled' => false,
'magic_link_expiry' => 30,
'login_notification_enabled' => true,
])
->call('save')
->assertHasNoFormErrors()
->assertNotified();

$settings = new AuthSettings();
$settings = new AuthSettings;

$this->assertFalse($settings->magic_link_enabled);
$this->assertSame(30, $settings->magic_link_expiry);
$this->assertTrue($settings->login_notification_enabled);
}

#[DataProvider('invalidExpiryProvider')]
Expand All @@ -73,7 +77,7 @@ public function test_invalid_expiry_does_not_change_authentication_settings(
->assertHasFormErrors(['magic_link_expiry' => $rule])
->assertNotNotified();

$settings = new AuthSettings();
$settings = new AuthSettings;

$this->assertTrue($settings->magic_link_enabled);
$this->assertSame(15, $settings->magic_link_expiry);
Expand Down
Loading