feat(scanner): implement password reset attempt functionality for scanner users
This commit is contained in:
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Auth\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
||||||
|
use App\Domains\Auth\Requests\ScannerCreateResetPasswordAttemptRequest;
|
||||||
|
use App\Domains\Auth\Services\ResetPasswordAttemptService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class CreateScannerResetPasswordAttemptController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ResetPasswordAttemptService $resetPasswordAttemptService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(ScannerCreateResetPasswordAttemptRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->resetPasswordAttemptService->createForScannerEmail(
|
||||||
|
$request->validated('email'),
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'code' => 'auth.password_reset_requested',
|
||||||
|
'message' => __('api.auth.password_reset_requested'),
|
||||||
|
'status' => ResetPasswordAttempt::STATUS_PENDING,
|
||||||
|
], 202);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Auth\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class ScannerCreateResetPasswordAttemptRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$email = $this->input('email');
|
||||||
|
|
||||||
|
if (is_string($email)) {
|
||||||
|
$this->merge(['email' => Str::lower(trim($email))]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'email' => ['required', 'string', 'email', 'max:255'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -93,6 +93,52 @@ class ResetPasswordAttemptService
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function createForScannerEmail(string $email, string $reason = 'manual'): void
|
||||||
|
{
|
||||||
|
$emailFingerprint = $this->emailFingerprint($email);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?array {
|
||||||
|
$user = User::query()
|
||||||
|
->where('email', $email)
|
||||||
|
->where('rol_codigo', RoleCode::Scanner->value)
|
||||||
|
->whereNotNull('tenant_codigo')
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$attemptId = $this->createAttemptForUser(
|
||||||
|
$user,
|
||||||
|
$reason,
|
||||||
|
$emailFingerprint,
|
||||||
|
'Scanner password reset attempt was not created because the user was not found.',
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($user === null || $attemptId === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'attempt_id' => $attemptId,
|
||||||
|
'tenant_code' => $user->tenant_codigo,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
Log::error('Failed to create Scanner password reset attempt.', [
|
||||||
|
'email_fingerprint' => $emailFingerprint,
|
||||||
|
'exception' => $exception,
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->dispatchPasswordResetRequested(
|
||||||
|
$result['attempt_id'] ?? null,
|
||||||
|
$result['tenant_code'] ?? null,
|
||||||
|
PasswordResetRequested::CHANNEL_SCANNER,
|
||||||
|
$emailFingerprint,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function validateCode(string $email, string $code): bool
|
public function validateCode(string $email, string $code): bool
|
||||||
{
|
{
|
||||||
$emailFingerprint = $this->emailFingerprint($email);
|
$emailFingerprint = $this->emailFingerprint($email);
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Auth\Controllers\CreateScannerResetPasswordAttemptController;
|
||||||
|
use App\Domains\Auth\Controllers\ResetPasswordController;
|
||||||
use App\Domains\Auth\Controllers\ScannerLoginController;
|
use App\Domains\Auth\Controllers\ScannerLoginController;
|
||||||
use App\Domains\Auth\Controllers\ScannerMeController;
|
use App\Domains\Auth\Controllers\ScannerMeController;
|
||||||
|
use App\Domains\Auth\Controllers\ValidateResetPasswordAttemptController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::prefix('v1/scanner')->group(function (): void {
|
Route::prefix('v1/scanner')->group(function (): void {
|
||||||
Route::post('login', ScannerLoginController::class)->middleware('throttle:login');
|
Route::post('login', ScannerLoginController::class)->middleware('throttle:login');
|
||||||
|
Route::post('password/reset-attempts', CreateScannerResetPasswordAttemptController::class)
|
||||||
|
->middleware('throttle:5,1');
|
||||||
|
Route::post('password/reset-attempts/validate', ValidateResetPasswordAttemptController::class)
|
||||||
|
->middleware('throttle:10,1');
|
||||||
|
Route::post('password/reset', ResetPasswordController::class)
|
||||||
|
->middleware('throttle:5,1');
|
||||||
Route::middleware(['auth:sanctum', 'scanner.tenant'])
|
Route::middleware(['auth:sanctum', 'scanner.tenant'])
|
||||||
->get('me', ScannerMeController::class);
|
->get('me', ScannerMeController::class);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ class PasswordResetRequested
|
|||||||
|
|
||||||
public const CHANNEL_ADMINAPP = 'adminapp';
|
public const CHANNEL_ADMINAPP = 'adminapp';
|
||||||
|
|
||||||
|
public const CHANNEL_SCANNER = 'scanner';
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public readonly int $attemptId,
|
public readonly int $attemptId,
|
||||||
public readonly string $tenantCode,
|
public readonly string $tenantCode,
|
||||||
|
|||||||
@@ -56,9 +56,11 @@ class NotificationMailService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$recoveryDomain = $channel === PasswordResetRequested::CHANNEL_ADMINAPP
|
$recoveryDomain = match ($channel) {
|
||||||
? $tenant->websiteType?->dominio
|
PasswordResetRequested::CHANNEL_ADMINAPP => $tenant->websiteType?->dominio,
|
||||||
: $tenant->dominio;
|
PasswordResetRequested::CHANNEL_SCANNER => $tenant->websiteType?->scanner_domain,
|
||||||
|
default => $tenant->dominio,
|
||||||
|
};
|
||||||
$recoveryUrl = $recoveryDomain === null
|
$recoveryUrl = $recoveryDomain === null
|
||||||
? null
|
? null
|
||||||
: 'https://'.$recoveryDomain.'/recuperar-contrasena/codigo?email='.urlencode($attempt->user->email);
|
: 'https://'.$recoveryDomain.'/recuperar-contrasena/codigo?email='.urlencode($attempt->user->email);
|
||||||
|
|||||||
100
tests/Feature/Auth/ScannerResetPasswordControllerTest.php
Normal file
100
tests/Feature/Auth/ScannerResetPasswordControllerTest.php
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Auth;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
|
use App\Domains\Authorization\Models\Role;
|
||||||
|
use App\Domains\Notification\Events\PasswordResetRequested;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ScannerResetPasswordControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_it_creates_an_attempt_for_a_tenant_bound_scanner_user(): void
|
||||||
|
{
|
||||||
|
Event::fake([PasswordResetRequested::class]);
|
||||||
|
Role::query()->create([
|
||||||
|
'codigo' => RoleCode::Scanner->value,
|
||||||
|
'nombre' => 'Scanner',
|
||||||
|
]);
|
||||||
|
$tenant = Tenant::query()->create([
|
||||||
|
'codigo' => 'acme',
|
||||||
|
'nombre' => 'Acme',
|
||||||
|
'dominio' => 'store.acme.test',
|
||||||
|
]);
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'email' => 'scanner@example.com',
|
||||||
|
'rol_codigo' => RoleCode::Scanner->value,
|
||||||
|
'tenant_codigo' => $tenant->codigo,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/scanner/password/reset-attempts', [
|
||||||
|
'email' => ' SCANNER@EXAMPLE.COM ',
|
||||||
|
])->assertAccepted()->assertJsonPath('status', ResetPasswordAttempt::STATUS_PENDING);
|
||||||
|
|
||||||
|
$attempt = $user->resetPasswordAttempts()->sole();
|
||||||
|
Event::assertDispatched(
|
||||||
|
PasswordResetRequested::class,
|
||||||
|
fn (PasswordResetRequested $event): bool => $event->attemptId === $attempt->id
|
||||||
|
&& $event->tenantCode === $tenant->codigo
|
||||||
|
&& $event->channel === PasswordResetRequested::CHANNEL_SCANNER,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_does_not_create_an_attempt_for_a_non_scanner_user(): void
|
||||||
|
{
|
||||||
|
Event::fake([PasswordResetRequested::class]);
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/scanner/password/reset-attempts', [
|
||||||
|
'email' => User::factory()->create()->email,
|
||||||
|
])->assertAccepted()->assertJsonPath('status', ResetPasswordAttempt::STATUS_PENDING);
|
||||||
|
|
||||||
|
$this->assertDatabaseCount('reset_password_attempts', 0);
|
||||||
|
Event::assertNotDispatched(PasswordResetRequested::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_scanner_can_complete_the_password_reset_flow(): void
|
||||||
|
{
|
||||||
|
Event::fake([PasswordResetRequested::class]);
|
||||||
|
Role::query()->create([
|
||||||
|
'codigo' => RoleCode::Scanner->value,
|
||||||
|
'nombre' => 'Scanner',
|
||||||
|
]);
|
||||||
|
$tenant = Tenant::query()->create([
|
||||||
|
'codigo' => 'acme',
|
||||||
|
'nombre' => 'Acme',
|
||||||
|
'dominio' => 'store.acme.test',
|
||||||
|
]);
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'email' => 'scanner@example.com',
|
||||||
|
'rol_codigo' => RoleCode::Scanner->value,
|
||||||
|
'tenant_codigo' => $tenant->codigo,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/scanner/password/reset-attempts', [
|
||||||
|
'email' => $user->email,
|
||||||
|
])->assertAccepted();
|
||||||
|
|
||||||
|
$attempt = $user->resetPasswordAttempts()->sole();
|
||||||
|
$this->postJson('/api/v1/scanner/password/reset-attempts/validate', [
|
||||||
|
'email' => $user->email,
|
||||||
|
'codigo' => $attempt->codigo,
|
||||||
|
])->assertOk()->assertJsonPath('status', ResetPasswordAttempt::STATUS_VALIDATED);
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/scanner/password/reset', [
|
||||||
|
'email' => $user->email,
|
||||||
|
'codigo' => $attempt->codigo,
|
||||||
|
'password' => 'NewScanner!123',
|
||||||
|
'password_confirmation' => 'NewScanner!123',
|
||||||
|
])->assertOk()->assertJsonPath('status', ResetPasswordAttempt::STATUS_USED);
|
||||||
|
|
||||||
|
$this->assertTrue(Hash::check('NewScanner!123', $user->fresh()->password));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,9 +7,11 @@ use App\Domains\Attachable\Models\Attachment;
|
|||||||
use App\Domains\Auth\Models\User;
|
use App\Domains\Auth\Models\User;
|
||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
use App\Domains\Integration\Models\Integration;
|
use App\Domains\Integration\Models\Integration;
|
||||||
|
use App\Domains\Notification\Events\PasswordResetRequested;
|
||||||
use App\Domains\Notification\Services\NotificationMailService;
|
use App\Domains\Notification\Services\NotificationMailService;
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Domains\Tenant\Models\WebsiteType;
|
||||||
use App\Domains\Ticket\Models\Ticket;
|
use App\Domains\Ticket\Models\Ticket;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Mail\Mailable;
|
use Illuminate\Mail\Mailable;
|
||||||
@@ -103,6 +105,35 @@ class NotificationMailServiceTest extends TestCase
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_it_links_scanner_password_resets_to_the_scanner_domain(): void
|
||||||
|
{
|
||||||
|
$websiteType = WebsiteType::query()->create([
|
||||||
|
'codigo' => 'scanner-mail',
|
||||||
|
'nombre' => 'Scanner Mail',
|
||||||
|
'dominio' => 'admin.mail.local',
|
||||||
|
'scanner_domain' => 'scanner.mail.local',
|
||||||
|
]);
|
||||||
|
$this->tenant->update(['website_type_code' => $websiteType->codigo]);
|
||||||
|
$attempt = $this->user->resetPasswordAttempts()->create([
|
||||||
|
'codigo' => '0123',
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(NotificationMailService::class)->sendPasswordResetCode(
|
||||||
|
$attempt->id,
|
||||||
|
$this->tenant->codigo,
|
||||||
|
PasswordResetRequested::CHANNEL_SCANNER,
|
||||||
|
);
|
||||||
|
|
||||||
|
Mail::assertSent(Mailable::class, function (Mailable $mail): bool {
|
||||||
|
$rendered = $mail->render();
|
||||||
|
|
||||||
|
return str_contains(
|
||||||
|
$rendered,
|
||||||
|
'https://scanner.mail.local/recuperar-contrasena/codigo?email=ada%40example.com',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public function test_it_sends_purchase_and_ticket_emails_to_the_purchase_recipient(): void
|
public function test_it_sends_purchase_and_ticket_emails_to_the_purchase_recipient(): void
|
||||||
{
|
{
|
||||||
$purchase = Purchase::query()->create([
|
$purchase = Purchase::query()->create([
|
||||||
|
|||||||
Reference in New Issue
Block a user