diff --git a/database/factories/LoginFactory.php b/database/factories/LoginFactory.php index 571bab9..4f4dd5f 100644 --- a/database/factories/LoginFactory.php +++ b/database/factories/LoginFactory.php @@ -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', ], ]; } diff --git a/src/IPAddressHandler.php b/src/IPAddressHandler.php index 7ceaede..af1e23c 100644 --- a/src/IPAddressHandler.php +++ b/src/IPAddressHandler.php @@ -3,6 +3,7 @@ namespace Zaengle\LaravelSecurityNotifications; use Zaengle\LaravelSecurityNotifications\Exceptions\IPAddressDriverMissingException; +use Zaengle\LaravelSecurityNotifications\Objects\IPAddressHandlerObject; class IPAddressHandler { @@ -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(); } } } \ No newline at end of file diff --git a/src/Jobs/ProcessNewIPAddress.php b/src/Jobs/ProcessNewIPAddress.php index 99548ba..d5abf13 100644 --- a/src/Jobs/ProcessNewIPAddress.php +++ b/src/Jobs/ProcessNewIPAddress.php @@ -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 { @@ -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, @@ -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) { @@ -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']; } } diff --git a/src/Objects/IPAddressHandlerObject.php b/src/Objects/IPAddressHandlerObject.php new file mode 100644 index 0000000..0ef1cfb --- /dev/null +++ b/src/Objects/IPAddressHandlerObject.php @@ -0,0 +1,66 @@ +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"); + } + } + } +} \ No newline at end of file diff --git a/src/Objects/IPLocationData.php b/src/Objects/IPLocationData.php new file mode 100644 index 0000000..002e73b --- /dev/null +++ b/src/Objects/IPLocationData.php @@ -0,0 +1,60 @@ +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"); + } + } + } +} \ No newline at end of file diff --git a/src/Services/IPAddressDriver.php b/src/Services/IPAddressDriver.php index 8f1086a..1d7ed49 100644 --- a/src/Services/IPAddressDriver.php +++ b/src/Services/IPAddressDriver.php @@ -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 { @@ -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([ @@ -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( diff --git a/src/Traits/Securable.php b/src/Traits/Securable.php index de78ed1..fc92ead 100644 --- a/src/Traits/Securable.php +++ b/src/Traits/Securable.php @@ -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; @@ -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), )); } } diff --git a/tests/Unit/Events/SecureFieldsUpdatedTest.php b/tests/Unit/Events/SecureFieldsUpdatedTest.php index 2c7c371..81b0c8f 100644 --- a/tests/Unit/Events/SecureFieldsUpdatedTest.php +++ b/tests/Unit/Events/SecureFieldsUpdatedTest.php @@ -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; @@ -14,6 +15,8 @@ $user = User::factory()->create(); + Login::factory()->for($user)->create(); + $originalEmail = $user->email; $user->update([ diff --git a/tests/Unit/IPAddressHandlerTest.php b/tests/Unit/IPAddressHandlerTest.php index 3221134..03c9c23 100644 --- a/tests/Unit/IPAddressHandlerTest.php +++ b/tests/Unit/IPAddressHandlerTest.php @@ -1,6 +1,5 @@ 'Minneapolis', 'region' => 'MN', 'countryCode' => 'US', + 'timezone' => 'America/Chicago', ]); IPAddress::process([ @@ -44,13 +43,9 @@ 'userType' => $user->getMorphClass(), ]); - Bus::assertDispatched(ProcessNewIPAddress::class, function ($job) use ($user) { - return $job->ipLocationData === [ - 'query' => '127.0.0.1', - 'city' => 'Minneapolis', - 'region' => 'MN', - 'countryCode' => 'US', - ] + Bus::assertDispatched(ProcessNewIPAddress::class, function (ProcessNewIPAddress $job) use ($user) { + return $job->ipLocationData['ipAddress'] === '127.0.0.1' + && $job->ipLocationData['timezone'] === 'America/Chicago' && $job->userId === $user->getKey() && $job->userType === $user->getMorphClass(); }); @@ -77,6 +72,7 @@ 'city' => $login->location_data['city'], 'region' => $login->location_data['region'], 'countryCode' => $login->location_data['countryCode'], + 'timezone' => $login->location_data['timezone'], ]); IPAddress::process([ @@ -98,8 +94,13 @@ 'first_login_at' => now()->subDays(10), 'last_login_at' => now()->subDays(5), 'location_data' => [ + 'ipAddress' => '127.0.0.1', 'city' => 'Minneapolis', 'region' => 'MN', + 'regionName' => 'Minnesota', + 'country' => 'United States', + 'countryCode' => 'US', + 'timezone' => 'America/Chicago', ], ]); @@ -117,7 +118,10 @@ 'query' => '127.0.0.1', 'city' => 'Minneapolis', 'region' => 'MN', + 'regionName' => 'Minnesota', + 'country' => 'United States', 'countryCode' => 'US', + 'timezone' => 'America/Chicago', ]); IPAddress::process([ @@ -159,6 +163,7 @@ 'city' => 'Minneapolis', 'region' => 'MN', 'countryCode' => 'US', + 'timezone' => 'America/Chicago', ]); IPAddress::process([ @@ -167,13 +172,9 @@ 'userType' => $login->user_type, ]); - Bus::assertDispatched(ProcessNewIPAddress::class, function ($job) use ($login) { - return $job->ipLocationData === [ - 'query' => '127.0.0.1', - 'city' => 'Minneapolis', - 'region' => 'MN', - 'countryCode' => 'US', - ] + Bus::assertDispatched(ProcessNewIPAddress::class, function (ProcessNewIPAddress $job) use ($login) { + return $job->ipLocationData['ipAddress'] === '127.0.0.1' + && $job->ipLocationData['timezone'] === 'America/Chicago' && $job->userId === $login->user->getKey() && $job->userType === $login->user->getMorphClass(); }); @@ -218,7 +219,10 @@ 'query' => '128.0.0.1', 'city' => 'Minneapolis', 'region' => 'MN', + 'regionName' => 'Minnesota', + 'country' => 'United States', 'countryCode' => 'US', + 'timezone' => 'America/Chicago', ]); IPAddress::process([ @@ -294,6 +298,7 @@ 'city' => 'Minneapolis', 'region' => 'MN', 'countryCode' => 'US', + 'timezone' => 'America/Chicago', ]); IPAddress::process([ diff --git a/tests/Unit/Jobs/ProcessNewIPAddressTest.php b/tests/Unit/Jobs/ProcessNewIPAddressTest.php index 31e9955..651526e 100644 --- a/tests/Unit/Jobs/ProcessNewIPAddressTest.php +++ b/tests/Unit/Jobs/ProcessNewIPAddressTest.php @@ -1,10 +1,10 @@ toBe(0); (new ProcessNewIPAddress( - ipLocationData: [ - 'query' => '127.0.0.1', - 'city' => 'Minneapolis', - 'region' => 'MN', + ipLocationData: new IPLocationData([ + 'ipAddress' => '127.0.0.1', 'countryCode' => 'US', - ], + 'region' => 'MN', + 'city' => 'Minneapolis', + 'timezone' => 'America/Chicago', + ]), userId: $user->getKey(), userType: $user->getMorphClass(), ))->handle(); @@ -31,10 +32,11 @@ 'user_id' => $user->getKey(), 'user_type' => $user->getMorphClass(), 'location_data' => json_encode([ - 'query' => '127.0.0.1', - 'city' => 'Minneapolis', - 'region' => 'MN', + 'ipAddress' => '127.0.0.1', 'countryCode' => 'US', + 'region' => 'MN', + 'city' => 'Minneapolis', + 'timezone' => 'America/Chicago', ]), ]); @@ -49,12 +51,13 @@ $user = User::factory()->create(); (new ProcessNewIPAddress( - ipLocationData: [ - 'query' => '127.0.0.1', - 'city' => 'Minneapolis', - 'region' => 'MN', + ipLocationData: new IPLocationData([ + 'ipAddress' => '127.0.0.1', 'countryCode' => 'US', - ], + 'region' => 'MN', + 'city' => 'Minneapolis', + 'timezone' => 'America/Chicago', + ]), userId: $user->getKey(), userType: $user->getMorphClass(), sendNewIpNotification: false, @@ -65,10 +68,11 @@ 'user_id' => $user->getKey(), 'user_type' => $user->getMorphClass(), 'location_data' => json_encode([ - 'query' => '127.0.0.1', - 'city' => 'Minneapolis', - 'region' => 'MN', + 'ipAddress' => '127.0.0.1', 'countryCode' => 'US', + 'region' => 'MN', + 'city' => 'Minneapolis', + 'timezone' => 'America/Chicago', ]), ]);