Implementing Suspicious Login Detection & "Wasn't Me" in Laravel

A couple of months ago, I was working on SOC related tasks, and as you might guess, alerting users to suspicious activity was part of it. In this article, I'll show you how to easily notify users of unusual login attempts and how to log them out of all sessions using Laravel's built-in middleware.

Tracking User Login Attempts

To track login attempts, let's create a model with a migration:

php artisan make:model LoginAttempt -m

Next, we need to add the necessary columns to store some data. The migration file will look like this:

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('login_attempts', function (Blueprint $table) {
            $table->id();
            $table->foreignIdFor(User::class)->constrained()->cascadeOnDelete();
            $table->string('ip');
            $table->string('country_code');
            $table->string('region_name');
            $table->string('city_name');
            $table->string('platform');
            $table->timestamps();

            $table->index(['user_id', 'created_at']);
        });
    }
};

Now let's update the LoginAttempt model. We probably don't want to store every login attempt forever, so we'll make use of Laravel's Prunable trait and specify that we have no interest in data older than 6 months:

class LoginAttempt extends Model
{
    use HasFactory, Prunable;

    protected $fillable = [
        'ip',
        'country_code',
        'region_name',
        'city_name',
        'platform',
    ];

    public function prunable(): Builder
    {
        return static::where('created_at', '<=', now()->minus(months: 6));
    }
}

We'll also need a factory for our tests:

class LoginAttemptFactory extends Factory
{
    protected $model = LoginAttempt::class;

    public function definition(): array
    {
        return [
            'user_id' => User::factory(),
            'ip' => $this->faker->ipv4(),
            'country_code' => $this->faker->countryCode(),
            'region_name' => $this->faker->state(),
            'city_name' => $this->faker->city(),
            'platform' => $this->faker->randomElement(['iOS', 'Android', 'Windows', 'macOS']),
        ];
    }
}

Now we need to create a listener for Laravel's Login event, which is triggered on each login:

php artisan make:listener RecordLoginAttempt

To get all the data we need, we'll install two packages, one to extract location data from the IP address and another to detect the user's device:

composer require stevebauman/location && composer require hisorange/browser-detect

You can find more details about these packages here:

Before we jump into RecordLoginAttempt, let's write a test:

php artisan make:test RecordLoginAttemptTest

First, we want to make sure that the listener actually creates records in the login_attempts table. We need to fake the location to avoid a real IP address lookup, mock the Request, and dispatch Laravel's Login event for the user. I've also modified the server variables to add the IP address and user agent:

it('records user successful login', function () {
    Location::fake([
        '85.76.12.34' => Position::make([
            'countryName' => 'Finland',
            'countryCode' => 'FI',
            'regionName' => 'Uusimaa',
            'cityName' => 'Helsinki',
            'ip' => '85.76.12.34',
        ]),
    ]);

    $this->app->instance('request', Request::create('/login', 'POST', server: [
        'REMOTE_ADDR' => '85.76.12.34',
        'HTTP_USER_AGENT' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
    ]));

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

    event(new Login('web', $user, false));

    $this->assertDatabaseHas('login_attempts', [
        'ip' => '85.76.12.34',
        'country_code' => 'FI',
        'region_name' => 'Uusimaa',
        'city_name' => 'Helsinki',
        'platform' => 'iOS',
        'user_id' => $user->id,
    ]);
});

To make the test pass, let's update our RecordLoginAttempt listener and User model:

class RecordLoginAttempt implements ShouldQueue
{
    public function handle(Login $event): void
    {
        /** @var User $user */
        $user = $event->user;

        $location = Location::get(request()->ip());

        if (! $location) {
            return;
        }

        $user->loginAttempts()->create([
            'ip' => $location->ip,
            'country_code' => $location->countryCode,
            'region_name' => $location->regionName,
            'city_name' => $location->cityName,
            'platform' => Browser::platformFamily(),
        ]);
    }
}

class User extends Authenticatable
{
    // ...

    public function loginAttempts(): HasMany
    {
        return $this->hasMany(LoginAttempt::class);
    }
}

Note: Since the listener performs a couple of operations including an external API call, it's a good idea to implement the ShouldQueue contract.

Greenlight.

The next step is to verify that Laravel's Prunable trait is working correctly for the LoginAttempt model:

it('prunes login attempts older than 6 months', function () {
    $user = User::factory()->create();

    $oldAttempt = LoginAttempt::factory()->create([
        'user_id' => $user->id,
        'created_at' => now()->subMonths(7),
    ]);

    $recentAttempt = LoginAttempt::factory()->create([
        'user_id' => $user->id,
        'created_at' => now()->subMonths(3),
    ]);

    $this->artisan('model:prune', ['--model' => LoginAttempt::class]);

    $this->assertDatabaseMissing('login_attempts', ['id' => $oldAttempt->id]);
    $this->assertDatabaseHas('login_attempts', ['id' => $recentAttempt->id]);
});

it('does not prune login attempts newer than 6 months', function () {
    $user = User::factory()->create();

    $attempts = [
        LoginAttempt::factory()->create([
            'user_id' => $user->id,
            'created_at' => now()->subMonths(5),
        ]),
        LoginAttempt::factory()->create([
            'user_id' => $user->id,
            'created_at' => now()->subMonth(),
        ]),
        LoginAttempt::factory()->create([
            'user_id' => $user->id,
            'created_at' => now()->subDays(7),
        ]),
    ];

    $this->artisan('model:prune', ['--model' => LoginAttempt::class]);

    foreach ($attempts as $attempt) {
        $this->assertDatabaseHas('login_attempts', ['id' => $attempt->id]);
    }
});

Greenlight.

Creating a Notification

Since we want to notify users of suspicious events, we'll create a notification:

php artisan make:notification UnusualLoginAttempt

I won't add any tests for the notification itself since it's straightforward and can be easily previewed like this:

use App\Models\User;
use App\Notifications\UnusualLoginAttempt;

Route::get('/notification', function () {
    $user = User::find(1);

    return (new UnusualLoginAttempt('Helsinki', 'Uusimaa'))
        ->toMail($user);
});

We're going to use one of my favorite Laravel features: Signed URLs. We'll create a route and controller for user.confirm-not-me shortly.

Here's what UnusualLoginAttempt will look like:

class UnusualLoginAttempt extends Notification
{
    use Queueable;

    public function __construct(public string $cityName, public string $regionName)
    {
        //
    }

    public function via(object $notifiable): array
    {
        return ['mail'];
    }

    public function toMail(object $notifiable): MailMessage
    {
        $url = URL::temporarySignedRoute('user.confirm-not-me', now()->addDay(), [
            'user' => $notifiable,
        ]);

        $time = now()
            ->setTimezone($notifiable->timezone ?? config('app.timezone'))
            ->format('g:ia T \o\n F j, Y');

        $domain = str_replace('https://', '', config('app.url'));

        return (new MailMessage)
            ->from(config('app.emails.security'))
            ->greeting("Hi {$notifiable->name},")
            ->line("We noticed that you logged into {$domain} at {$time} near {$this->cityName}, {$this->regionName}.")
            ->line('If this wasn\'t you, click here to reset your password.')
            ->action('Wasn\'t me', $url)
            ->line('Thank you for using our application!');
    }
}

Sending a Notification on Unusual Login Attempts

Let's define what we mean by "unusual login attempt." This depends on your application's requirements, but I think we can compare each login attempt against the 5 most recent logins. If the login is unusual, our system will send an UnusualLoginAttempt notification.

First, add the configuration value to your config/app.php:

'recent_login_attempts' => env('RECENT_LOGIN_ATTEMPTS', 5),

Now let's write a test to make sure our application doesn't send a notification on every login attempt:

it('does not send notification to user on usual login attempt', function () {
    Notification::fake();

    Location::fake([
        '85.76.12.34' => Position::make([
            'countryName' => 'Finland',
            'countryCode' => 'FI',
            'regionName' => 'Uusimaa',
            'cityName' => 'Helsinki',
            'ip' => '85.76.12.34',
        ]),
    ]);

    $this->app->instance('request', Request::create('/login', 'POST', server: [
        'REMOTE_ADDR' => '85.76.12.34',
        'HTTP_USER_AGENT' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
    ]));

    $user = User::factory()
        ->has(
            LoginAttempt::factory()
                ->count(config('app.recent_login_attempts'))
                ->state([
                    'ip' => '85.76.12.34',
                    'country_code' => 'FI',
                    'region_name' => 'Uusimaa',
                    'city_name' => 'Helsinki',
                    'platform' => 'iOS',
                ])
        )
        ->create();

    event(new Login('web', $user, false));

    Notification::assertNotSentTo($user, UnusualLoginAttempt::class);
});

Greenlight.

Now let's add two more tests to ensure UnusualLoginAttempt is sent when the login attempt is from a different country or device (you can add as many checks as you need):

it('sends notification to user on unusual country login attempt', function () {
    Notification::fake();

    Location::fake([
        '86.120.79.84' => Position::make([
            'countryName' => 'Romania',
            'countryCode' => 'RO',
            'regionName' => 'Brasov',
            'cityName' => 'Brasov',
            'ip' => '86.120.79.84',
        ]),
    ]);

    $this->app->instance('request', Request::create('/login', 'POST', server: [
        'REMOTE_ADDR' => '86.120.79.84',
        'HTTP_USER_AGENT' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
    ]));

    $user = User::factory()
        ->has(
            LoginAttempt::factory()
                ->count(config('app.recent_login_attempts'))
                ->state([
                    'ip' => '85.76.12.34',
                    'country_code' => 'FI',
                    'region_name' => 'Uusimaa',
                    'city_name' => 'Helsinki',
                    'platform' => 'iOS',
                ])
        )
        ->create();

    event(new Login('web', $user, false));

    Notification::assertSentTo($user, UnusualLoginAttempt::class);
});

it('sends notification to user on unusual device login attempt', function () {
    Notification::fake();

    Location::fake([
        '85.76.12.34' => Position::make([
            'countryName' => 'Finland',
            'countryCode' => 'FI',
            'regionName' => 'Uusimaa',
            'cityName' => 'Helsinki',
            'ip' => '85.76.12.34',
        ]),
    ]);

    $this->app->instance('request', Request::create('/login', 'POST', server: [
        'REMOTE_ADDR' => '85.76.12.34',
        'HTTP_USER_AGENT' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15',
    ]));

    $user = User::factory()
        ->has(
            LoginAttempt::factory()
                ->count(config('app.recent_login_attempts'))
                ->state([
                    'ip' => '85.76.12.34',
                    'country_code' => 'FI',
                    'region_name' => 'Uusimaa',
                    'city_name' => 'Helsinki',
                    'platform' => 'iOS',
                ])
        )
        ->create();

    event(new Login('web', $user, false));

    Notification::assertSentTo($user, UnusualLoginAttempt::class);
});

Now we need to update the RecordLoginAttempt listener to match our tests. It performs simple checks to determine whether the login attempt is normal and sends an UnusualLoginAttempt notification otherwise:

class RecordLoginAttempt implements ShouldQueue
{
    public function handle(Login $event): void
    {
        /** @var User $user */
        $user = $event->user;

        $location = Location::get(request()->ip());

        if (! $location) {
            return;
        }

        if ($user->loginAttempts()->count() >= config('app.recent_login_attempts')) {
            $recentAttempts = $user->loginAttempts()
                ->latest()
                ->take(config('app.recent_login_attempts'))
                ->get();

            $knownCountries = $recentAttempts->pluck('country_code')->unique();
            $knownCities = $recentAttempts->pluck('city_name')->unique();
            $knownPlatforms = $recentAttempts->pluck('platform')->unique();

            if (! $knownCountries->contains($location->countryCode) ||
                ! $knownCities->contains($location->cityName) ||
                ! $knownPlatforms->contains(Browser::platformFamily())
            ) {
                Notification::send($user, new UnusualLoginAttempt($location->cityName, $location->regionName));
            }
        }

        $user->loginAttempts()->create([
            'ip' => $location->ip,
            'country_code' => $location->countryCode,
            'region_name' => $location->regionName,
            'city_name' => $location->cityName,
            'platform' => Browser::platformFamily(),
        ]);
    }
}

Note: For brand new users with no login history, the notification logic is skipped. This is intentional, there's nothing to compare against yet. You could optionally send a welcome email on first login instead.

Greenlight.

Logging Out Users from All Devices

First, we need to add Laravel's Illuminate\Session\Middleware\AuthenticateSession middleware to the web group in bootstrap/app.php:

->withMiddleware(function (Middleware $middleware): void {
    $middleware->appendToGroup('web', [
        \Illuminate\Session\Middleware\AuthenticateSession::class,
    ]);
})

This middleware ensures that if a user changes their password, all their other active sessions are automatically invalidated. This prevents attackers who may have stolen an old session cookie from continuing to access the account.

Of course, we can't move forward without a test:

it('has authenticate session middleware in web group', function () {
    expect(Route::getMiddlewareGroups()['web'])
        ->toContain(\Illuminate\Session\Middleware\AuthenticateSession::class);
});

Now all we need to do is set the user's password to a random value and redirect them to the password.reset route when "Wasn't me" is clicked.

Let's add a route to routes/web.php:

Route::get('user-security/confirm-not-me/{user}', ConfirmNotMeController::class)
    ->middleware('signed')
    ->name('user.confirm-not-me');

And the corresponding test:

it('requires a valid signature', function () {
    $user = User::factory()->create();

    $response = $this->get(route('user.confirm-not-me', [
        'user' => $user->id,
    ]));

    $response->assertForbidden();
});

Greenlight.

Finally, we need to implement the ConfirmNotMeController. Here's the test:

it('resets password and terminates all sessions when user confirms unauthorized login', function () {
    $user = User::factory()->create([
        'password' => Hash::make('secret'),
    ]);

    $this->actingAs($user);

    $signedUrl = URL::temporarySignedRoute(
        'user.confirm-not-me',
        now()->addDay(),
        ['user' => $user]
    );

    $this
        ->get($signedUrl)
        ->assertRedirectContains('reset-password');

    $user->refresh();

    $this->assertFalse(Hash::check('secret', $user->password));

    $this->get(route('home'));

    $this->assertGuest();
});

And now we're ready to write the controller:

class ConfirmNotMeController extends Controller
{
    public function __invoke(User $user)
    {
        $user->forceFill([
            'password' => Hash::make(Str::random(40)),
            'remember_token' => Str::random(40),
        ]);

        $user->saveQuietly();

        return to_route('password.reset', [
            'token' => Password::createToken($user),
            'email' => $user->email,
        ]);
    }
}

Greenlight.

Afterword

Alerting users to suspicious activity is a crucial part of many applications, and this article covered just one aspect of SOC 2 compliance. With the help of a couple of packages, implementing unusual login detection and "wasn't me" functionality is a breeze in Laravel.

Have something in mind?

A project, a question, or just a hello.

Your email
Subject
Message