18. Laravel Sanctum. Вам не треба JWT. Авторизація в Laravel. Laravel FormRequest sanitizer
Відео версія
(YouTube - для зворотнього зв'язку)
JWT - занадто складний підхід. У більшості проєктів він не потрібен. Використовуйте JWT тільки якщо знаєте, навіщо він потрібен вашому проєкту. Авторизація в банківський кабінет - нормальний кейс для JWT. Авторизація для інтернет-магазину - ні.
Laravel Sanctum авторизує за допомогою токену з довгим часом життя.
"Вічний" токен - ок. Але обов'язково додайте функціонал сесій - нотифікації про нову або підозрілу авторизацію та можливість вийти з пристроїв.
Тестуйте негативні кейси. Якщо авторизація обов'язкова, пишіть тести, які перевірять, що неавторизований користувач не може використати метод!
Зберігайте email у нижньому регістрі! І в такому ж регістрі проводьте пошук. Дуже зручний пакет для попередньої FormRequest
Для API об'єкт
User
таPersonalAccessToken
тягнеться при кожному запиті. Раджу мінімізувати розміри цих об'єктів. Особливо User. Краще створіть 1 до 1 запис, ніж пихайте туди текстову біографію користувача.
Приберіть ID з auth токену.
<?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);
}
}