Skip to content

Feature/localization #4

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Dec 10, 2024
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
4 changes: 3 additions & 1 deletion database/factories/LoginFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@ public function definition(): array
return [
'user_id' => User::factory(),
'user_type' => (new User)->getMorphClass(),
'ip_address' => $this->faker->ipv4,
'ip_address' => $ipAddress = $this->faker->ipv4,
'first_login_at' => now(),
'last_login_at' => now(),
'location_data' => [
'ipAddress' => $ipAddress,
'city' => $this->faker->city,
'region' => 'MN',
'countryCode' => $this->faker->countryCode,
'timezone' => 'America/Chicago',
],
];
}
Expand Down
5 changes: 4 additions & 1 deletion src/IPAddressHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Zaengle\LaravelSecurityNotifications;

use Zaengle\LaravelSecurityNotifications\Exceptions\IPAddressDriverMissingException;
use Zaengle\LaravelSecurityNotifications\Objects\IPAddressHandlerObject;

class IPAddressHandler
{
Expand All @@ -15,7 +16,9 @@ public function process(array $options): void
throw new IPAddressDriverMissingException("IP address driver [{$ipAddressDriver}] not found.");
}

(new $ipAddressDriver(...$options))->handle();
(new $ipAddressDriver(
...app(IPAddressHandlerObject::class, ['input' => $options])->input
))->handle();
}
}
}
17 changes: 10 additions & 7 deletions src/Jobs/ProcessNewIPAddress.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Zaengle\LaravelSecurityNotifications\Models\Login;
use Zaengle\LaravelSecurityNotifications\Objects\IPLocationData;

class ProcessNewIPAddress implements ShouldBeUnique, ShouldQueue
{
Expand All @@ -20,7 +21,7 @@ class ProcessNewIPAddress implements ShouldBeUnique, ShouldQueue
protected ?Model $user;

public function __construct(
public readonly array $ipLocationData,
public readonly IPLocationData $ipLocationData,
public readonly int $userId,
public readonly string $userType,
public readonly bool $sendNewIpNotification = true,
Expand All @@ -35,14 +36,16 @@ public function handle(): void
throw new Exception('User does not exist.');
}

$localizedTime = Carbon::now($this->ipLocationData['timezone']);

/** @var Login $login */
$login = Login::create([
'ip_address' => Arr::get($this->ipLocationData, 'query'),
'ip_address' => $this->ipLocationData['ipAddress'],
'user_id' => $this->userId,
'user_type' => $this->userType,
'first_login_at' => now(),
'last_login_at' => now(),
'location_data' => $this->ipLocationData,
'first_login_at' => $localizedTime,
'last_login_at' => $localizedTime,
'location_data' => $this->ipLocationData->input,
]);

if (config('security-notifications.send_notifications') && $this->sendNewIpNotification) {
Expand All @@ -54,6 +57,6 @@ public function handle(): void

public function uniqueId(): string
{
return $this->userId.'-'.Arr::get($this->ipLocationData, 'query');
return $this->userId.'-'.$this->ipLocationData['ipAddress'];
}
}
66 changes: 66 additions & 0 deletions src/Objects/IPAddressHandlerObject.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php

namespace Zaengle\LaravelSecurityNotifications\Objects;

use ArrayObject;
use InvalidArgumentException;
use ReflectionClass;

class IPAddressHandlerObject extends ArrayObject
{
public function __construct(public array $input)
{
$this->validate($input);

parent::__construct($input);
}

private function validate(array $input): void
{
$handlerParams = collect(
(new ReflectionClass(config('security-notifications.ip_address_driver')))
->getMethod('__construct')
->getParameters()
);

// Check for invalid keys
$paramNames = $handlerParams
->map(fn ($param) => $param->getName())
->toArray();

foreach ($input as $key => $value) {
if (! in_array($key, $paramNames)) {
throw new InvalidArgumentException("Invalid key: $key");
}
}

// Check for missing required keys
$requiredParams = $handlerParams
->reject(fn ($param) => $param->isOptional())
->map(fn ($param) => $param->getName())
->toArray();

foreach ($requiredParams as $paramName) {
if (! array_key_exists($paramName, $input)) {
throw new InvalidArgumentException("Missing required key: $paramName");
}
}

// Check for invalid types
$paramTypes = $handlerParams
->mapWithKeys(fn ($param) => [$param->getName() => $param->getType()->getName()])
->toArray();

foreach ($input as $key => $value) {
$type = match ($paramTypes[$key]) {
'int' => 'integer',
'bool' => 'boolean',
default => $paramTypes[$key],
};

if ($type !== gettype($value)) {
throw new InvalidArgumentException("Invalid type for key: $key");
}
}
}
}
60 changes: 60 additions & 0 deletions src/Objects/IPLocationData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

namespace Zaengle\LaravelSecurityNotifications\Objects;

use ArrayObject;
use InvalidArgumentException;

class IPLocationData extends ArrayObject
{
private array $requiredKeys = [
'ipAddress',
'countryCode',
'region',
'city',
'timezone',
];

private array $allowedKeys = [
'country',
'regionName',
'status',
'continent',
'continentCode',
'district',
'zip',
'lat',
'lon',
'offset',
'currency',
'isp',
'org',
'as',
'asname',
'mobile',
'proxy',
'hosting',
];

public function __construct(public array $input)
{
$this->validate($input);

parent::__construct($input);
}

private function validate(array $input): void
{
foreach ($this->requiredKeys as $key) {
if (! array_key_exists($key, $input)) {
throw new InvalidArgumentException("Missing required key: $key");
}
}

foreach (array_keys($input) as $key) {
if (! in_array($key, array_merge($this->requiredKeys, $this->allowedKeys))) {
throw new InvalidArgumentException("Invalid key: $key");
}
}
}
}
27 changes: 17 additions & 10 deletions src/Services/IPAddressDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
namespace Zaengle\LaravelSecurityNotifications\Services;

use Exception;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Http;
use Zaengle\LaravelSecurityNotifications\Jobs\ProcessNewIPAddress;
use Zaengle\LaravelSecurityNotifications\Models\Login;
use Zaengle\LaravelSecurityNotifications\Objects\IPLocationData;

readonly class IPAddressDriver implements DigestIPAddress
{
Expand All @@ -25,12 +26,18 @@ public function handle(): void
? 'https://pro.ip-api.com/json/'
: 'https://ip-api.com/json/';

$ipLocationData = Http::retry(3)
$ipResponse = Http::retry(3)
->withQueryParameters(['key' => config('security-notifications.ip-api-key')])
->get($endpoint.$this->ipAddress)
?->json();

throw_if(is_null($ipLocationData), new Exception('Failed to get IP location data for: '.$this->ipAddress));
throw_if(is_null($ipResponse), new Exception('Failed to get IP location data for: '.$this->ipAddress));

unset($ipResponse['query']);

$ipResponse['ipAddress'] = $this->ipAddress;

$ipLocationData = new IPLocationData($ipResponse);

$loginQuery = Login::query()
->where([
Expand All @@ -42,21 +49,21 @@ public function handle(): void

if (config('security-notifications.allow_same_location_login')) {
$loginQuery->when(
$existenceCheckQuery->where('ip_address', $this->ipAddress)->exists(),
fn ($query) => $query->where('ip_address', $this->ipAddress),
$existenceCheckQuery->where('ip_address', $ipLocationData['ipAddress'])->exists(),
fn ($query) => $query->where('ip_address', $ipLocationData['ipAddress']),
fn ($query) => $query->where([
'location_data->city' => Arr::get($ipLocationData, 'city'),
'location_data->region' => Arr::get($ipLocationData, 'region'),
'location_data->city' => $ipLocationData['city'],
'location_data->region' => $ipLocationData['region'],
])
);
} else {
$loginQuery = $loginQuery->where('ip_address', $this->ipAddress);
$loginQuery = $loginQuery->where('ip_address', $ipLocationData['ipAddress']);
}

if ($login = $loginQuery->first()) {
$login->update([
'ip_address' => $this->ipAddress,
'last_login_at' => now(),
'ip_address' => $ipLocationData['ipAddress'],
'last_login_at' => Carbon::now($ipLocationData['timezone']),
]);
} else {
ProcessNewIPAddress::dispatch(
Expand Down
5 changes: 4 additions & 1 deletion src/Traits/Securable.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Support\Arr;
use Zaengle\LaravelSecurityNotifications\Events\SecureFieldsUpdated;
use Zaengle\LaravelSecurityNotifications\Models\Login;

Expand All @@ -28,11 +29,13 @@ public static function handleUpdatedSecureFields(Model $model): void
$changedSecureFields = collect($model->getChanges())->only($model->getSecureFields());

if ($changedSecureFields->count()) {
$timezone = Arr::get($model->logins()->latest()->first()?->location_data, 'timezone', 'UTC');

event(new SecureFieldsUpdated(
$model,
$changedSecureFields->toArray(),
$model->sendSecurityEmailsTo(),
$model->refresh()->updated_at,
$model->refresh()->updated_at->setTimezone($timezone),
));
}
}
Expand Down
3 changes: 3 additions & 0 deletions tests/Unit/Events/SecureFieldsUpdatedTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Event;
use Zaengle\LaravelSecurityNotifications\Events\SecureFieldsUpdated;
use Zaengle\LaravelSecurityNotifications\Models\Login;
use Zaengle\LaravelSecurityNotifications\Tests\Setup\Models\CustomUser;
use Zaengle\LaravelSecurityNotifications\Tests\Setup\Models\User;

Expand All @@ -14,6 +15,8 @@

$user = User::factory()->create();

Login::factory()->for($user)->create();

$originalEmail = $user->email;

$user->update([
Expand Down
Loading
Loading