feat(scanner): add scanner authentication and context services with routes and tests
This commit is contained in:
42
app/Domains/Auth/Controllers/ScannerLoginController.php
Normal file
42
app/Domains/Auth/Controllers/ScannerLoginController.php
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Auth\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Requests\ScannerLoginRequest;
|
||||||
|
use App\Domains\Auth\Resources\UserResource;
|
||||||
|
use App\Domains\Auth\Services\PasswordLoginService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class ScannerLoginController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly PasswordLoginService $passwordLoginService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(ScannerLoginRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$credentials = $request->validated();
|
||||||
|
$user = $this->passwordLoginService->authenticateScanner(
|
||||||
|
$credentials['email'],
|
||||||
|
$credentials['password'],
|
||||||
|
$request->ip(),
|
||||||
|
$request->userAgent(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$expirationMinutes = (int) config('sanctum.expiration');
|
||||||
|
$token = $user->createToken(
|
||||||
|
'scanner-token',
|
||||||
|
['scanner'],
|
||||||
|
now()->addMinutes($expirationMinutes),
|
||||||
|
)->plainTextToken;
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'code' => 'auth.login_success',
|
||||||
|
'message' => __('api.auth.login_success'),
|
||||||
|
'token' => $token,
|
||||||
|
'token_type' => 'Bearer',
|
||||||
|
'user' => UserResource::make($user),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
app/Domains/Auth/Controllers/ScannerMeController.php
Normal file
24
app/Domains/Auth/Controllers/ScannerMeController.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Auth\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Auth\Resources\ScannerMeResource;
|
||||||
|
use App\Domains\Auth\Services\ScannerContextService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class ScannerMeController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ScannerContextService $scannerContextService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(Request $request): ScannerMeResource
|
||||||
|
{
|
||||||
|
/** @var User $user */
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
return ScannerMeResource::make($this->scannerContextService->load($user));
|
||||||
|
}
|
||||||
|
}
|
||||||
5
app/Domains/Auth/Requests/ScannerLoginRequest.php
Normal file
5
app/Domains/Auth/Requests/ScannerLoginRequest.php
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Auth\Requests;
|
||||||
|
|
||||||
|
class ScannerLoginRequest extends AdminAppLoginRequest {}
|
||||||
21
app/Domains/Auth/Resources/ScannerMeResource.php
Normal file
21
app/Domains/Auth/Resources/ScannerMeResource.php
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Auth\Resources;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Tenant\Resources\TenantResource;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/** @mixin User */
|
||||||
|
class ScannerMeResource extends JsonResource
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'user' => UserResource::make($this->resource),
|
||||||
|
'tenant' => TenantResource::make($this->tenant),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,6 +62,29 @@ class PasswordLoginService
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticate a tenant-bound scanner without requiring its tenant code.
|
||||||
|
*
|
||||||
|
* @throws AccountLockedException
|
||||||
|
* @throws ValidationException
|
||||||
|
*/
|
||||||
|
public function authenticateScanner(
|
||||||
|
string $email,
|
||||||
|
string $password,
|
||||||
|
?string $ipAddress,
|
||||||
|
?string $userAgent,
|
||||||
|
): User {
|
||||||
|
return $this->authenticateUser(
|
||||||
|
$email,
|
||||||
|
$password,
|
||||||
|
null,
|
||||||
|
$ipAddress,
|
||||||
|
$userAgent,
|
||||||
|
RoleCode::Scanner,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private function authenticateUser(
|
private function authenticateUser(
|
||||||
string $email,
|
string $email,
|
||||||
string $password,
|
string $password,
|
||||||
@@ -210,7 +233,7 @@ class PasswordLoginService
|
|||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Log::error('Failed to trigger reset password on account lock', [
|
Log::error('Failed to trigger reset password on account lock', [
|
||||||
'user_id' => $user->id,
|
'user_id' => $user->id,
|
||||||
'exception' => $e
|
'exception' => $e,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
25
app/Domains/Auth/Services/ScannerContextService.php
Normal file
25
app/Domains/Auth/Services/ScannerContextService.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Auth\Services;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
|
|
||||||
|
class ScannerContextService
|
||||||
|
{
|
||||||
|
public function load(User $user): User
|
||||||
|
{
|
||||||
|
$tenant = $user->tenant()
|
||||||
|
->with([
|
||||||
|
'menues' => fn ($query) => $query->whereHas(
|
||||||
|
'roles',
|
||||||
|
fn ($query) => $query->where('codigo', RoleCode::Scanner->value)
|
||||||
|
),
|
||||||
|
])
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$user->setRelation('tenant', $tenant);
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,3 +25,4 @@ Route::middleware('auth:sanctum')->get('/me', MeController::class);
|
|||||||
Route::middleware('auth:sanctum')->put('/me', UpdateProfileController::class);
|
Route::middleware('auth:sanctum')->put('/me', UpdateProfileController::class);
|
||||||
|
|
||||||
require __DIR__.'/adminapp.php';
|
require __DIR__.'/adminapp.php';
|
||||||
|
require __DIR__.'/scanner.php';
|
||||||
|
|||||||
11
app/Domains/Auth/routes/scanner.php
Normal file
11
app/Domains/Auth/routes/scanner.php
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Auth\Controllers\ScannerLoginController;
|
||||||
|
use App\Domains\Auth\Controllers\ScannerMeController;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
Route::prefix('v1/scanner')->group(function (): void {
|
||||||
|
Route::post('login', ScannerLoginController::class)->middleware('throttle:login');
|
||||||
|
Route::middleware(['auth:sanctum', 'scanner.tenant'])
|
||||||
|
->get('me', ScannerMeController::class);
|
||||||
|
});
|
||||||
@@ -29,6 +29,13 @@ class MenuSeeder extends Seeder
|
|||||||
'content_type' => Menu::CONTENT_TYPE_DYNAMIC,
|
'content_type' => Menu::CONTENT_TYPE_DYNAMIC,
|
||||||
'route' => '/',
|
'route' => '/',
|
||||||
],
|
],
|
||||||
|
['code' => 'scanner.inicio', 'label' => 'Inicio', 'route' => '/scanner/inicio'],
|
||||||
|
['code' => 'scanner.scan', 'label' => 'Escanear', 'route' => '/scanner/scan'],
|
||||||
|
[
|
||||||
|
'code' => 'scanner.detail',
|
||||||
|
'label' => 'Detalle',
|
||||||
|
'route' => '/scanner/detail/:id',
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'code' => 'adminapp.event',
|
'code' => 'adminapp.event',
|
||||||
'label' => 'Eventos',
|
'label' => 'Eventos',
|
||||||
@@ -219,8 +226,11 @@ class MenuSeeder extends Seeder
|
|||||||
->where('code', 'main.adminapp')
|
->where('code', 'main.adminapp')
|
||||||
->orWhere('parent_menu_code', 'main.adminapp')
|
->orWhere('parent_menu_code', 'main.adminapp')
|
||||||
->pluck('code');
|
->pluck('code');
|
||||||
|
$scannerMenuCodes = Menu::query()
|
||||||
|
->where('code', 'like', 'scanner.%')
|
||||||
|
->pluck('code');
|
||||||
$userMenuCodes = Menu::query()
|
$userMenuCodes = Menu::query()
|
||||||
->whereNotIn('code', $adminAppMenuCodes)
|
->whereNotIn('code', $adminAppMenuCodes->merge($scannerMenuCodes))
|
||||||
->pluck('code');
|
->pluck('code');
|
||||||
|
|
||||||
Role::query()
|
Role::query()
|
||||||
@@ -231,6 +241,10 @@ class MenuSeeder extends Seeder
|
|||||||
->where('codigo', RoleCode::User->value)
|
->where('codigo', RoleCode::User->value)
|
||||||
->each(fn (Role $role) => $role->menus()->sync($userMenuCodes));
|
->each(fn (Role $role) => $role->menus()->sync($userMenuCodes));
|
||||||
|
|
||||||
|
Role::query()
|
||||||
|
->where('codigo', RoleCode::Scanner->value)
|
||||||
|
->each(fn (Role $role) => $role->menus()->sync($scannerMenuCodes));
|
||||||
|
|
||||||
$tenants = Tenant::all();
|
$tenants = Tenant::all();
|
||||||
|
|
||||||
$allMenus = Menu::pluck('code')->toArray();
|
$allMenus = Menu::pluck('code')->toArray();
|
||||||
|
|||||||
66
tests/Feature/Auth/ScannerLoginControllerTest.php
Normal file
66
tests/Feature/Auth/ScannerLoginControllerTest.php
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Auth;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
|
use App\Domains\Authorization\Models\Role;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ScannerLoginControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_it_logs_in_a_tenant_bound_scanner(): void
|
||||||
|
{
|
||||||
|
$role = Role::query()->create([
|
||||||
|
'codigo' => RoleCode::Scanner->value,
|
||||||
|
'nombre' => 'Scanner',
|
||||||
|
]);
|
||||||
|
$tenant = Tenant::query()->create([
|
||||||
|
'codigo' => 'acme',
|
||||||
|
'nombre' => 'Acme',
|
||||||
|
'dominio' => 'acme.test',
|
||||||
|
]);
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'email' => 'scanner@example.com',
|
||||||
|
'password' => Hash::make('secret123'),
|
||||||
|
'rol_codigo' => $role->codigo,
|
||||||
|
'tenant_codigo' => $tenant->codigo,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->postJson('/api/v1/scanner/login', [
|
||||||
|
'email' => ' SCANNER@EXAMPLE.COM ',
|
||||||
|
'password' => 'secret123',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('user.id', $user->id)
|
||||||
|
->assertJsonPath('user.rol_codigo', RoleCode::Scanner->value)
|
||||||
|
->assertJsonPath('token_type', 'Bearer');
|
||||||
|
|
||||||
|
$this->assertSame(['scanner'], $user->tokens()->sole()->abilities);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_rejects_a_non_scanner_user(): void
|
||||||
|
{
|
||||||
|
$role = Role::query()->create([
|
||||||
|
'codigo' => RoleCode::User->value,
|
||||||
|
'nombre' => 'Usuario',
|
||||||
|
]);
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'email' => 'customer@example.com',
|
||||||
|
'password' => Hash::make('secret123'),
|
||||||
|
'rol_codigo' => $role->codigo,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/scanner/login', [
|
||||||
|
'email' => $user->email,
|
||||||
|
'password' => 'secret123',
|
||||||
|
])->assertUnprocessable()->assertJsonValidationErrors(['email']);
|
||||||
|
}
|
||||||
|
}
|
||||||
61
tests/Feature/Auth/ScannerMeControllerTest.php
Normal file
61
tests/Feature/Auth/ScannerMeControllerTest.php
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Auth;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
|
use App\Domains\Authorization\Models\Role;
|
||||||
|
use App\Domains\Menu\Models\Menu;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Laravel\Sanctum\Sanctum;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ScannerMeControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_it_returns_only_scanner_menus_assigned_to_the_tenant(): void
|
||||||
|
{
|
||||||
|
$scannerRole = Role::query()->create([
|
||||||
|
'codigo' => RoleCode::Scanner->value,
|
||||||
|
'nombre' => 'Scanner',
|
||||||
|
]);
|
||||||
|
$tenant = Tenant::query()->create([
|
||||||
|
'codigo' => 'acme',
|
||||||
|
'nombre' => 'Acme',
|
||||||
|
'dominio' => 'acme.test',
|
||||||
|
]);
|
||||||
|
$home = Menu::query()->create([
|
||||||
|
'code' => 'scanner.inicio',
|
||||||
|
'label' => 'Inicio',
|
||||||
|
'route' => '/scanner/inicio',
|
||||||
|
]);
|
||||||
|
$scan = Menu::query()->create([
|
||||||
|
'code' => 'scanner.scan',
|
||||||
|
'label' => 'Escanear',
|
||||||
|
'route' => '/scanner/scan',
|
||||||
|
]);
|
||||||
|
$foreign = Menu::query()->create([
|
||||||
|
'code' => 'adminapp.inicio',
|
||||||
|
'label' => 'Administración',
|
||||||
|
'route' => '/admin/inicio',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$scannerRole->menus()->sync([$home->code, $scan->code]);
|
||||||
|
$tenant->menues()->sync([$home->code, $scan->code, $foreign->code]);
|
||||||
|
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'rol_codigo' => $scannerRole->codigo,
|
||||||
|
'tenant_codigo' => $tenant->codigo,
|
||||||
|
]);
|
||||||
|
Sanctum::actingAs($user);
|
||||||
|
|
||||||
|
$this->getJson('/api/v1/scanner/me')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.user.id', $user->id)
|
||||||
|
->assertJsonPath('data.tenant.codigo', $tenant->codigo)
|
||||||
|
->assertJsonCount(2, 'data.tenant.menues')
|
||||||
|
->assertJsonMissing(['code' => $foreign->code]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -124,8 +124,13 @@ class MenuSeederTest extends TestCase
|
|||||||
->where('code', 'main.adminapp')
|
->where('code', 'main.adminapp')
|
||||||
->orWhere('parent_menu_code', 'main.adminapp')
|
->orWhere('parent_menu_code', 'main.adminapp')
|
||||||
->pluck('code');
|
->pluck('code');
|
||||||
|
$scannerMenuCodes = Menu::query()
|
||||||
|
->where('code', 'like', 'scanner.%')
|
||||||
|
->orderBy('code')
|
||||||
|
->pluck('code')
|
||||||
|
->all();
|
||||||
$userMenuCodes = Menu::query()
|
$userMenuCodes = Menu::query()
|
||||||
->whereNotIn('code', $adminAppMenuCodes)
|
->whereNotIn('code', [...$adminAppMenuCodes, ...$scannerMenuCodes])
|
||||||
->orderBy('code')
|
->orderBy('code')
|
||||||
->pluck('code')
|
->pluck('code')
|
||||||
->all();
|
->all();
|
||||||
@@ -153,9 +158,23 @@ class MenuSeederTest extends TestCase
|
|||||||
$this->assertSame($userMenuCodes, $userRoleMenuCodes);
|
$this->assertSame($userMenuCodes, $userRoleMenuCodes);
|
||||||
$this->assertNotContains('main.adminapp', $userRoleMenuCodes);
|
$this->assertNotContains('main.adminapp', $userRoleMenuCodes);
|
||||||
$this->assertNotContains('adminapp.catalog', $userRoleMenuCodes);
|
$this->assertNotContains('adminapp.catalog', $userRoleMenuCodes);
|
||||||
|
|
||||||
|
$scannerRoleMenuCodes = Role::query()
|
||||||
|
->where('codigo', RoleCode::Scanner->value)
|
||||||
|
->firstOrFail()
|
||||||
|
->menus()
|
||||||
|
->orderBy('menues.code')
|
||||||
|
->pluck('menues.code')
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$this->assertSame([
|
||||||
|
'scanner.detail',
|
||||||
|
'scanner.inicio',
|
||||||
|
'scanner.scan',
|
||||||
|
], $scannerRoleMenuCodes);
|
||||||
$this->assertDatabaseCount(
|
$this->assertDatabaseCount(
|
||||||
'roles_menues',
|
'roles_menues',
|
||||||
(count($allMenuCodes) * 2) + count($userMenuCodes)
|
(count($allMenuCodes) * 2) + count($userMenuCodes) + count($scannerMenuCodes)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user