Skip to content

Commit

Permalink
feat: Added mobile app token login email
Browse files Browse the repository at this point in the history
  • Loading branch information
lewislarsen committed Aug 12, 2024
1 parent 71b6075 commit 9c37273
Show file tree
Hide file tree
Showing 11 changed files with 234 additions and 6 deletions.
11 changes: 10 additions & 1 deletion app/Http/Controllers/Api/AuthenticateDeviceController.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@
namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Mail\User\DeviceAuthenticationLogIn;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

Expand Down Expand Up @@ -41,7 +43,9 @@ public function __invoke(Request $request): JsonResponse
]);
}

$token = $user->createToken($credentials['device_name'])->plainTextToken;
$token = $user->createMobileToken($credentials['device_name'])->plainTextToken;

$this->sendEmail($user);

return response()->json(['token' => $token]);
}
Expand Down Expand Up @@ -87,4 +91,9 @@ private function checkPassword(User $user, string $password): bool
{
return Hash::check($password, (string) $user->getAttribute('password'));
}

private function sendEmail(User $user): void
{
Mail::to($user->getAttribute('email'))->queue(new DeviceAuthenticationLogIn($user));
}
}
56 changes: 56 additions & 0 deletions app/Mail/User/DeviceAuthenticationLogIn.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

declare(strict_types=1);

namespace App\Mail\User;

use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;

/**
* Mailable class for notifying users about a new device login.
*
* This class constructs and sends an email to the user when a new login
* to their account is detected from a mobile device, providing security
* awareness and prompting them to review their account activity.
*/
class DeviceAuthenticationLogIn extends Mailable implements ShouldQueue
{
use Queueable;
use SerializesModels;

public function __construct(
/**
* The user instance.
*/
private readonly User $user
) {}

/**
* Get the message envelope.
*/
public function envelope(): Envelope
{
return new Envelope(
subject: __('Security Alert: New Device Login Detected'),
);
}

/**
* Get the message content definition.
*/
public function content(): Content
{
return new Content(
markdown: 'mail.user.device-authentication-log-in',
with: [
'user' => $this->user,
]
);
}
}
38 changes: 38 additions & 0 deletions app/Models/PersonalAccessToken.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace App\Models;

use Laravel\Sanctum\PersonalAccessToken as SanctumPersonalAccessToken;

/**
* Represents a personal access token.
*
* This model extends Sanctum's personal access token and allows for checking
* if the token is a mobile token.
*/
class PersonalAccessToken extends SanctumPersonalAccessToken
{
protected $table = 'personal_access_tokens';

/**
* Determine whether the token is a mobile token or not.
*/
public function isMobileToken(): bool
{
return (bool) $this->getAttribute('mobile_at');
}

/**
* Get the casts array for the model's attributes.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'mobile_at' => 'bool',
];
}
}
26 changes: 26 additions & 0 deletions app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@

use Carbon\Carbon;
use Database\Factories\UserFactory;
use DateTimeInterface;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
use Laravel\Sanctum\NewAccessToken;

/**
* Represents a user in the system.
Expand Down Expand Up @@ -216,6 +218,30 @@ public function generateBackupSummaryData(array $dateRange): array
];
}

/**
* Create a new mobile personal access token for the user.
*
* @param string $name The name of the token.
* @param array<int|string, mixed> $abilities The abilities granted to the token. Defaults to all abilities.
* @param DateTimeInterface|null $expiresAt The expiration date of the token, if any.
* @return NewAccessToken The newly created access token.
*/
public function createMobileToken(string $name, array $abilities = ['*'], ?DateTimeInterface $expiresAt = null): NewAccessToken
{
$plainTextToken = $this->generateTokenString();

/** @var PersonalAccessToken $model */
$model = $this->tokens()->forceCreate([
'name' => $name,
'token' => hash('sha256', $plainTextToken),
'mobile_at' => now(),
'abilities' => $abilities,
'expires_at' => $expiresAt,
]);

return new NewAccessToken($model, $model->getKey() . '|' . $plainTextToken);
}

/**
* Get the casts array.
*
Expand Down
4 changes: 4 additions & 0 deletions app/Providers/AppServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@

namespace App\Providers;

use App\Models\PersonalAccessToken;
use App\Models\User;
use App\Services\GreetingService;
use Flare;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;
use Laravel\Sanctum\Sanctum;

/**
* Core application service provider.
Expand All @@ -31,6 +33,8 @@ public function register(): void
*/
public function boot(): void
{
Sanctum::usePersonalAccessTokenModel(PersonalAccessToken::class);

$this->defineGates();
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up(): void
{
Schema::table('personal_access_tokens', function (Blueprint $table) {
$table->dateTime('mobile_at')->nullable();
});
}
};
18 changes: 13 additions & 5 deletions resources/views/livewire/profile/api-token-manager.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -545,7 +545,17 @@ class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400
@foreach ($this->tokens as $token)
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">
{{ $token->name }}
<div class="flex items-center space-x-2">
@if ($token->isMobileToken())
<div title="{{ __('Token was used on a mobile device.') }}" class="flex items-center space-x-2 bg-cyan-100 dark:bg-cyan-600 rounded-full px-3 py-1">
<span class="text-cyan-600 dark:text-cyan-100">
@svg('heroicon-o-device-phone-mobile', 'w-5 h-5')
</span>
<span class="text-cyan-600 dark:text-cyan-100 text-xs font-semibold">{{ __('Mobile') }}</span>
</div>
@endif
<span>{{ $token->name }}</span>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{{ $token->created_at->diffForHumans() }}
Expand All @@ -554,12 +564,10 @@ class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400
{{ $token->last_used_at ? $token->last_used_at->diffForHumans() : __('Never') }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<x-secondary-button wire:click="viewTokenAbilities({{ $token->id }})" class="mr-2"
iconOnly title="{{ __('View Abilities') }}">
<x-secondary-button wire:click="viewTokenAbilities({{ $token->id }})" class="mr-2" iconOnly title="{{ __('View Abilities') }}">
@svg('heroicon-o-eye', 'w-4 h-4')
</x-secondary-button>
<x-danger-button wire:click="confirmApiTokenDeletion({{ $token->id }})"
wire:loading.attr="disabled" iconOnly title="{{ __('Revoke Token') }}">
<x-danger-button wire:click="confirmApiTokenDeletion({{ $token->id }})" wire:loading.attr="disabled" iconOnly title="{{ __('Revoke Token') }}">
@svg('heroicon-o-trash', 'w-4 h-4')
</x-danger-button>
</td>
Expand Down
20 changes: 20 additions & 0 deletions resources/views/mail/user/device-authentication-log-in.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<x-mail::message>
# Device Login

Hey {{ $user->first_name }},

We noticed a new login to your account from a mobile device.

If this was you, no further action is required. However, if you do not recognize this activity, we strongly recommend that you review your account settings and update your password immediately.

To manage your API tokens and review any recent activity, please click the button below:

<x-mail::button :url="route('profile.api')">
Review API Tokens
</x-mail::button>

If you have any questions or need assistance, please do not hesitate to contact our support team.

Best regards,
The {{ config('app.name') }} Team
</x-mail::message>
15 changes: 15 additions & 0 deletions tests/Feature/Auth/AuthenticateDeviceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

declare(strict_types=1);

use App\Mail\User\DeviceAuthenticationLogIn;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Config;
Expand All @@ -18,6 +19,7 @@

// Enable the device authentication endpoint by default for most tests
Config::set('app.enable_device_authentication_endpoint', true);
Mail::fake();
});

test('authenticates user and returns a token when endpoint is enabled', function (): void {
Expand All @@ -31,6 +33,14 @@
->assertJsonStructure(['token']);

expect($response->json('token'))->toBeString()->not->toBeEmpty();

$this->assertDatabaseHas('personal_access_tokens', [
'tokenable_id' => $this->user->id,
'name' => 'test_device',
'mobile_at' => now(),
]);

Mail::assertQueued(DeviceAuthenticationLogIn::class);
});

test('returns 404 when device authentication endpoint is disabled', function (): void {
Expand All @@ -43,6 +53,7 @@
]);

$response->assertStatus(404);
Mail::assertNotQueued(DeviceAuthenticationLogIn::class);
});

test('returns validation error for missing fields', function (): void {
Expand All @@ -61,6 +72,7 @@

$response->assertStatus(422)
->assertJsonValidationErrors(['email']);
Mail::assertNotQueued(DeviceAuthenticationLogIn::class);
});

test('returns error for incorrect credentials', function (): void {
Expand All @@ -72,6 +84,7 @@

$response->assertStatus(422)
->assertJsonValidationErrors(['email']);
Mail::assertNotQueued(DeviceAuthenticationLogIn::class);
});

test('returns error for non-existent user', function (): void {
Expand All @@ -83,6 +96,7 @@

$response->assertStatus(422)
->assertJsonValidationErrors(['email']);
Mail::assertNotQueued(DeviceAuthenticationLogIn::class);
});

test('creates a new token for an already authenticated user', function (): void {
Expand All @@ -98,4 +112,5 @@
->assertJsonStructure(['token']);

expect($response->json('token'))->toBeString()->not->toBeEmpty();
Mail::assertQueued(DeviceAuthenticationLogIn::class);
});
25 changes: 25 additions & 0 deletions tests/Unit/Models/PersonalAccessTokenTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

use App\Models\User;

it('returns true if the token is a mobile token', function (): void {
$user = User::factory()->create();

$user->createMobileToken('My Mobile Token');

$token = $user->tokens()->first();

$this->assertTrue($token->isMobileToken());
});

it('returns false if the token is not a mobile token', function (): void {
$user = User::factory()->create();

$user->createToken('My Regular Token');

$token = $user->tokens()->first();

$this->assertFalse($token->isMobileToken());
});
12 changes: 12 additions & 0 deletions tests/Unit/Models/UserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -284,3 +284,15 @@
->and($summaryData['failed_tasks'])->toBe(0)
->and($summaryData['success_rate'])->toBe(0);
});

it('generates a mobile api token', function (): void {

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

$user->createMobileToken('Test Token');

$this->assertDatabaseHas('personal_access_tokens', [
'name' => 'Test Token',
'mobile_at' => now(),
]);
});

0 comments on commit 9c37273

Please sign in to comment.