18. Laravel Sanctum. You dont need JWT. Authorization in Laravel. Laravel FormRequest sanitizer
Video version
(Leave your feedback on YouTube)
JWT is an overly complex approach. Most projects do not need it. Use JWT only if you know why your project needs it. Authorization for a banking dashboard is a good use case for JWT. Authorization for an online store is not.
Laravel Sanctum authorizes using a token with a long lifespan.
A "permanent" token is fine. But be sure to add session functionality - notifications about new or suspicious logins and the ability to log out from devices.
Test negative cases. If authorization is required, write tests to ensure that unauthorized users cannot use the method!
Store email addresses in lowercase! And conduct searches in lowercase as well. A very convenient package for pre-FormRequest.
For APIs, the
User
andPersonalAccessToken
objects are pulled with each request. I recommend minimizing the size of these objects, especially User. It's better to create a 1-to-1 record than to stuff a user's biography text in there.
Remove the ID from the auth token.
<?php
namespace Tests;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Modules\Auth\Models\User;
use Spectator\Spectator;
abstract class TestCase extends BaseTestCase
{
use CreatesApplication;
use RefreshDatabase;
private bool $dropTypes = true;
public function actingAs(
Authenticatable|User $user,
$guard = 'sanctum',
): TestCase {
$token = $user
->createToken();
$user->withAccessToken($token->accessToken);
app('auth')->guard($guard)->setUser($user);
app('auth')->shouldUse($guard);
return $this;
}
}
<?php
namespace App\Services;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
/**
* @codeCoverageIgnore
*
* @infection-ignore-all
*/
class ProjectStructure
{
public static function filesInModuleSubDir(string $subDirName): array
{
$modulesPath = base_path('Modules');
$modulesDirectories = glob($modulesPath . '/*', GLOB_ONLYDIR);
$directories = array_map(
fn ($path) => $path .'/' . $subDirName,
$modulesDirectories
);
$result = [];
foreach ($directories as $directory) {
if (! is_dir($directory)) {
continue;
}
$result = [
...$result,
...self::filesInDirectory($directory),
];
}
return $result;
}
private static function filesInDirectory(string $directoryPath): array
{
$directoryIterator = new RecursiveDirectoryIterator($directoryPath);
$recursiveIterator = new RecursiveIteratorIterator($directoryIterator);
$files = [];
foreach ($recursiveIterator as $file) {
if (! $file->isDir()) {
$files[] = $file->getPathname();
}
}
return $files;
}
}
return [
'config' => [
\SlevomatCodingStandard\Sniffs\Functions\UnusedParameterSniff::class => [
'exclude' => [
'app/Exceptions/Handler.php',
...ProjectStructure::filesInModuleSubDir('Transformers'),
]
],
]
<?php
namespace Modules\Auth\Providers;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Facades\App;
use Illuminate\Support\ServiceProvider;
use Laravel\Sanctum\Sanctum;
use Modules\Auth\Models\PersonalAccessToken;
use Modules\Auth\Models\User;
class AuthServiceProvider extends ServiceProvider
{
protected string $moduleName = 'Auth';
public function boot(): void
{
Sanctum::usePersonalAccessTokenModel(PersonalAccessToken::class);
$this->loadMigrations();
$this->enforceMorphAliases();
}
public function register(): void
{
$this->app->register(RouteServiceProvider::class);
}
private function loadMigrations(): void
{
$isTestEnv = App::environment('testing');
$runningInConsoleMigrateCommand = App::runningConsoleCommand('module:migrate');
/** @infection-ignore-all */
if ($isTestEnv || $runningInConsoleMigrateCommand) {
$this->loadMigrationsFrom(module_path($this->moduleName, 'Database/Migrations'));
}
}
private function enforceMorphAliases(): void
{
Relation::enforceMorphMap([
'user' => User::class,
]);
}
}
<?php
namespace Modules\Auth\Models;
use App\Traits\Sluggable;
use DateTimeInterface;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Support\Str;
use Laravel\Sanctum\HasApiTokens;
use Laravel\Sanctum\NewAccessToken;
use Modules\Auth\Database\Factories\UserFactory;
/**
* @mixin IdeHelperUser
*/
class User extends Authenticatable
{
use HasApiTokens;
use HasFactory;
use Sluggable;
public $timestamps = false;
protected $fillable = [
'name',
'email',
'password',
];
protected $hidden = [
'password',
];
protected $casts = [
'password' => 'hashed',
];
public function createToken(
?DateTimeInterface $expiresAt = null
): NewAccessToken {
$plainTextToken = $this->generateTokenString();
/** @var PersonalAccessToken $token */
$token = $this->tokens()->create([
'token' => hash('sha256', $plainTextToken),
'expires_at' => $expiresAt,
]);
return new NewAccessToken($token, $plainTextToken);
}
protected function email(): Attribute
{
return Attribute::make(
get: fn ($value) => $value,
set: fn ($value) => Str::lower($value)
);
}
protected static function newFactory(): UserFactory
{
return UserFactory::new();
}
protected function slugSource(): ?string
{
return 'name';
}
}
<?php
namespace Modules\Auth\Tests\Unit\Models;
use Modules\Auth\Models\User;
use Tests\TestCase;
class UserTest extends TestCase
{
/**
* @test
*/
public function canSaveOnlyLowerCasedEmail()
{
$email = 'TestWrongcase@test.com';
$user = User::create([
'name' => 'No matter',
'email' => $email,
'password' => 'no_matter',
]);
$this->assertDatabaseHas('users', [
'users.id' => $user->id,
'email' => strtolower($email),
]);
}
/**
* @test
*/
public function canSaveOnlyHashedPassword()
{
$password = 'some_password';
$user = User::create([
'name' => 'No matter',
'email' => 'some_name',
'password' => $password,
]);
$this->assertDatabaseHas('users', [
'users.id' => $user->id,
]);
$this->assertDatabaseMissing('users', [
'id' => $user->id,
'password' => $password,
]);
}
}
<?php
namespace Modules\Auth\Actions;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Support\Facades\Auth;
use Lorisleiva\Actions\Concerns\AsController;
use Modules\Auth\Models\User;
use Modules\Auth\Requests\LoginRequest;
use Modules\Auth\Transformers\LoginUserResource;
use Throwable;
class LoginUser
{
use AsController;
/**
* @throws Throwable
*/
public function handle(LoginRequest $request): LoginUserResource
{
$login = Auth::attempt($request->only(['email', 'password']));
throw_unless($login, new AuthenticationException());
/** @var User $user */
$user = $request->user();
$token = $user->createToken()->plainTextToken;
return new LoginUserResource([
'token' => $token,
]);
}
}
<?php
namespace Modules\Auth\Requests;
use ArondeParon\RequestSanitizer\Sanitizers\Lowercase;
use ArondeParon\RequestSanitizer\Traits\SanitizesInputs;
use Illuminate\Foundation\Http\FormRequest;
class LoginRequest extends FormRequest
{
use SanitizesInputs;
protected $sanitizers = [
'email' => [
Lowercase::class,
],
];
public function rules(): array
{
return [
'email' => ['required'],
'password' => ['required'],
];
}
}
<?php
namespace Modules\Auth\Transformers;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class LoginUserResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'token' => $this['token'],
];
}
}
<?php
namespace Modules\Auth\Tests\Feature;
use Modules\Auth\Models\PersonalAccessToken;
use Modules\Auth\Models\User;
use Tests\TestCase;
class LoginUserTest extends TestCase
{
public const array CREDENTIALS = [
'email' => 'some_email@test.com',
'password' => 'some_password',
];
/**
* @test
*/
public function canLogin(): void
{
User::factory()->create(self::CREDENTIALS);
$response = $this->postJson('/api/v1/login', self::CREDENTIALS);
$response->assertStatus(200);
}
/**
* @test
*/
public function canLoginEmailCaseInsensitive(): void
{
$email = 'some_email@test.com';
$password = 'some_password';
User::factory()->create([
'email' => $email,
'password' => $password,
]);
$response = $this->postJson('/api/v1/login', [
'email' => 'some_eMaiL@teSt.com',
'password' => $password,
]);
$response->assertStatus(200);
}
/**
* @test
*/
public function cannotLoginWithWrongPassword(): void
{
$user = User::factory()->create(self::CREDENTIALS);
$response = $this->postJson('/api/v1/login', [
'email' => $user->email,
'password' => 'wrong_password',
]);
$response->assertStatus(401);
}
/**
* @test
*/
public function cannotLoginWithWrongEmail(): void
{
User::factory()->create(self::CREDENTIALS);
$response = $this->postJson('/api/v1/login', [
'email' => 'soem_wrong_email@test.com',
'password' => self::CREDENTIALS['password'],
]);
$response->assertStatus(401);
}
/**
* @test
*/
public function canLoginAsActualUser(): void
{
User::factory()->create();
$user = User::factory()->create(self::CREDENTIALS);
User::factory()->create();
$response = $this->postJson('/api/v1/login', self::CREDENTIALS);
$plainTextToken = $response->json('token');
$token = PersonalAccessToken::findToken($plainTextToken);
$this->assertEquals($user->id, $token->tokenable_id);
}
/**
* @test
*/
public function cannotLoginWithoutEmail(): void
{
$this->postJson('/api/v1/login', [
'password' => 'some_password',
])
->assertInvalid(['email']);
}
/**
* @test
*/
public function cannotLoginWithoutPassword(): void
{
$this->postJson('/api/v1/login', [
'email' => 'some_email@test.com',
])
->assertInvalid(['password']);
}
/**
* @test
*/
public function canHideTokenIdDisplay(): void
{
User::factory()->create(self::CREDENTIALS);
$response = $this->postJson('/api/v1/login', self::CREDENTIALS);
$plainTextToken = $response->json('token');
$hasPipe = str_contains($plainTextToken, '|');
$this->assertFalse($hasPipe);
}
}
<?php
namespace Modules\Auth\Actions;
use Illuminate\Support\Facades\Auth;
use Lorisleiva\Actions\Concerns\AsController;
use Modules\Auth\Transformers\CurrentUserInfoResource;
class GetCurrentUserInfo
{
use AsController;
public function handle(): CurrentUserInfoResource
{
$user = Auth::user();
return new CurrentUserInfoResource($user);
}
}
<?php
namespace Modules\Auth\Transformers;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Modules\Auth\Models\User;
/**
* @mixin User
*/
class CurrentUserInfoResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'name' => $this->name,
'slug' => $this->slug,
];
}
}
<?php
namespace Modules\Auth\Tests\Feature;
use Modules\Auth\Models\User;
use Tests\TestCase;
class GetCurrentUserInfoTest extends TestCase
{
/**
* @test
*/
public function canGetCurrentUserInfo(): void
{
$user = User::factory()->create();
$user = $user->fresh();
$this->actingAs($user);
$response = $this->getJson('/api/v1/me');
$response->assertStatus(200);
}
/**
* @test
*/
public function cannotGetCurrentUserInfoWhenGuest(): void
{
$this->getJson('/api/v1/me')->assertStatus(401);
}
/**
* @test
*/
public function canGetOnlyAuthorizedUserInfo(): void
{
$name = 'some_unique_name';
User::factory()->create();
$user = User::factory()->create([
'name' => $name,
]);
User::factory()->create();
$this->actingAs($user);
$request = $this->getJson('/api/v1/me');
$request->assertJson([
'name' => $name,
'slug' => $user->slug,
]);
}
}
<?php
namespace Modules\Auth\Actions;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Auth;
use Lorisleiva\Actions\Concerns\AsController;
use Modules\Auth\Models\PersonalAccessToken;
class LogoutUser
{
use AsController;
public function handle(): Response
{
/** @var PersonalAccessToken $token */
$token = Auth::user()->currentAccessToken();
$token->delete();
return response()->noContent();
}
}
<?php
namespace Modules\Auth\Tests\Feature;
use Modules\Auth\Models\User;
use Tests\TestCase;
class LogoutUserTest extends TestCase
{
/**
* @test
*/
public function canLogout(): void
{
$user = User::factory()->create();
$this->actingAs($user);
$token = $user->currentAccessToken();
$request = $this->postJson('/api/v1/logout');
$request->assertValidRequest();
$request->assertValidResponse(204);
$this->assertSoftDeleted('personal_access_tokens', [
'id' => $token->id,
]);
}
/**
* @test
*/
public function cannotLogoutAsGuest(): void
{
$this->postJson('/api/v1/logout')
->assertStatus(401);
}
}