From a4a4c9afbc350e038e4b3cdee1cabcc39e524361 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 13 Aug 2026 16:49:19 -0300 Subject: [PATCH] feat(auth): enhance password reset attempt handling with new reasons and update related services --- .../Auth/Models/ResetPasswordAttempt.php | 6 +++ .../Auth/Services/PasswordLoginService.php | 46 ++++++++++++----- .../Services/ResetPasswordAttemptService.php | 16 ++++-- .../Services/NotificationMailService.php | 9 +++- app/Domains/Staff/Services/StaffService.php | 10 ++++ .../notifications/password-reset.blade.php | 12 +++-- .../Auth/ScannerLoginControllerTest.php | 50 +++++++++++++++++++ .../NotificationMailServiceTest.php | 9 ++-- tests/Feature/Staff/StaffControllerTest.php | 14 ++++++ 9 files changed, 149 insertions(+), 23 deletions(-) diff --git a/app/Domains/Auth/Models/ResetPasswordAttempt.php b/app/Domains/Auth/Models/ResetPasswordAttempt.php index 03b27e4..fd331ae 100644 --- a/app/Domains/Auth/Models/ResetPasswordAttempt.php +++ b/app/Domains/Auth/Models/ResetPasswordAttempt.php @@ -11,6 +11,12 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; #[Hidden(['codigo'])] class ResetPasswordAttempt extends Model { + public const REASON_MANUAL = 'manual'; + + public const REASON_ACCOUNT_LOCKED = 'account_locked'; + + public const REASON_STAFF_CREATED = 'staff_created'; + public const STATUS_PENDING = 'pending'; public const STATUS_VALIDATED = 'validated'; diff --git a/app/Domains/Auth/Services/PasswordLoginService.php b/app/Domains/Auth/Services/PasswordLoginService.php index a1bd28f..151cd21 100644 --- a/app/Domains/Auth/Services/PasswordLoginService.php +++ b/app/Domains/Auth/Services/PasswordLoginService.php @@ -4,9 +4,11 @@ namespace App\Domains\Auth\Services; use App\Domains\Auth\Exceptions\AccountLockedException; use App\Domains\Auth\Models\LoginAttempt; +use App\Domains\Auth\Models\ResetPasswordAttempt; use App\Domains\Auth\Models\User; use App\Domains\Authorization\Enums\PermissionCode; use App\Domains\Authorization\Enums\RoleCode; +use App\Domains\Notification\Events\PasswordResetRequested; use Carbon\CarbonImmutable; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; @@ -60,6 +62,8 @@ class PasswordLoginService $userAgent, RoleCode::AdminApp, true, + null, + PasswordResetRequested::CHANNEL_ADMINAPP, ); } @@ -84,6 +88,7 @@ class PasswordLoginService null, true, PermissionCode::ScanTickets->value, + PasswordResetRequested::CHANNEL_SCANNER, ); } @@ -96,6 +101,7 @@ class PasswordLoginService ?RoleCode $requiredRole = RoleCode::User, bool $requiresTenant = false, ?string $requiredPermission = null, + string $passwordResetChannel = PasswordResetRequested::CHANNEL_STOREFRONT, ): User { $normalizedEmail = mb_strtolower(trim($email)); $now = CarbonImmutable::now(); @@ -111,6 +117,7 @@ class PasswordLoginService $requiredRole, $requiresTenant, $requiredPermission, + $passwordResetChannel, ): array { $user = User::query() ->where('email', $normalizedEmail) @@ -160,7 +167,12 @@ class PasswordLoginService if ($user === null || ! Hash::check($password, $user->password)) { if ($user !== null && $attemptTenantCode !== null) { - $this->registerFailure($user, $now, $attemptTenantCode); + $this->registerFailure( + $user, + $now, + $attemptTenantCode, + $passwordResetChannel, + ); } $outcome = $user?->locked_until?->isFuture() @@ -219,7 +231,12 @@ class PasswordLoginService return $result['user']; } - private function registerFailure(User $user, CarbonImmutable $now, string $tenantCode): void + private function registerFailure( + User $user, + CarbonImmutable $now, + string $tenantCode, + string $passwordResetChannel, + ): void { $windowMinutes = max(1, (int) config('login-security.attempt_window_minutes')); $maxAttempts = max(1, (int) config('login-security.max_attempts')); @@ -243,18 +260,23 @@ class PasswordLoginService if ($attempts >= $maxAttempts && $previousAttempts < $maxAttempts) { try { - if ($user->rol_codigo === RoleCode::AdminApp->value) { - $this->resetPasswordAttemptService->createForAdminAppEmail( - $user->email, - 'account_locked', - ); - } else { - $this->resetPasswordAttemptService->createForEmail( + match ($passwordResetChannel) { + PasswordResetRequested::CHANNEL_ADMINAPP => + $this->resetPasswordAttemptService->createForAdminAppEmail( + $user->email, + ResetPasswordAttempt::REASON_ACCOUNT_LOCKED, + ), + PasswordResetRequested::CHANNEL_SCANNER => + $this->resetPasswordAttemptService->createForScannerEmail( + $user->email, + ResetPasswordAttempt::REASON_ACCOUNT_LOCKED, + ), + default => $this->resetPasswordAttemptService->createForEmail( $user->email, $tenantCode, - 'account_locked', - ); - } + ResetPasswordAttempt::REASON_ACCOUNT_LOCKED, + ), + }; } catch (\Throwable $e) { Log::error('Failed to trigger reset password on account lock', [ 'user_id' => $user->id, diff --git a/app/Domains/Auth/Services/ResetPasswordAttemptService.php b/app/Domains/Auth/Services/ResetPasswordAttemptService.php index 816f64d..f558b3c 100644 --- a/app/Domains/Auth/Services/ResetPasswordAttemptService.php +++ b/app/Domains/Auth/Services/ResetPasswordAttemptService.php @@ -12,7 +12,11 @@ use Throwable; class ResetPasswordAttemptService { - public function createForEmail(string $email, string $tenantCode, string $reason = 'manual'): void + public function createForEmail( + string $email, + string $tenantCode, + string $reason = ResetPasswordAttempt::REASON_MANUAL, + ): void { $emailFingerprint = $this->emailFingerprint($email); @@ -47,7 +51,10 @@ class ResetPasswordAttemptService ); } - public function createForAdminAppEmail(string $email, string $reason = 'manual'): void + public function createForAdminAppEmail( + string $email, + string $reason = ResetPasswordAttempt::REASON_MANUAL, + ): void { $emailFingerprint = $this->emailFingerprint($email); @@ -93,7 +100,10 @@ class ResetPasswordAttemptService ); } - public function createForScannerEmail(string $email, string $reason = 'manual'): void + public function createForScannerEmail( + string $email, + string $reason = ResetPasswordAttempt::REASON_MANUAL, + ): void { $emailFingerprint = $this->emailFingerprint($email); diff --git a/app/Domains/Notification/Services/NotificationMailService.php b/app/Domains/Notification/Services/NotificationMailService.php index f0c06d1..2fc9f0a 100644 --- a/app/Domains/Notification/Services/NotificationMailService.php +++ b/app/Domains/Notification/Services/NotificationMailService.php @@ -61,9 +61,16 @@ class NotificationMailService PasswordResetRequested::CHANNEL_SCANNER => $tenant->websiteType?->scanner_domain, default => $tenant->dominio, }; + $recoveryQuery = ['email' => $attempt->user->email]; + if ( + $channel === PasswordResetRequested::CHANNEL_SCANNER + && $attempt->reason === ResetPasswordAttempt::REASON_STAFF_CREATED + ) { + $recoveryQuery['code'] = $attempt->codigo; + } $recoveryUrl = $recoveryDomain === null ? null - : 'https://'.$recoveryDomain.'/recuperar-contrasena/codigo?email='.urlencode($attempt->user->email); + : 'https://'.$recoveryDomain.'/recuperar-contrasena/codigo?'.http_build_query($recoveryQuery); $this->mailService ->forTenant($tenantCode) diff --git a/app/Domains/Staff/Services/StaffService.php b/app/Domains/Staff/Services/StaffService.php index 9d53310..fc7c445 100644 --- a/app/Domains/Staff/Services/StaffService.php +++ b/app/Domains/Staff/Services/StaffService.php @@ -2,7 +2,9 @@ namespace App\Domains\Staff\Services; +use App\Domains\Auth\Models\ResetPasswordAttempt; use App\Domains\Auth\Models\User; +use App\Domains\Auth\Services\ResetPasswordAttemptService; use App\Domains\Authorization\Enums\RoleCode; use App\Domains\Catalog\Models\Category; use App\Domains\Tenant\Models\Tenant; @@ -15,6 +17,10 @@ use Illuminate\Validation\ValidationException; class StaffService { + public function __construct( + private readonly ResetPasswordAttemptService $resetPasswordAttemptService, + ) {} + /** @return Collection */ public function list(Tenant $tenant, ?string $search = null): Collection { @@ -60,6 +66,10 @@ class StaffService 'tenant_codigo' => $tenant->codigo, ]); $staff->scanCategories()->sync($categoryIds); + $this->resetPasswordAttemptService->createForScannerEmail( + $staff->email, + ResetPasswordAttempt::REASON_STAFF_CREATED, + ); return $staff->load('role', 'scanCategories'); }); diff --git a/resources/views/mail/notifications/password-reset.blade.php b/resources/views/mail/notifications/password-reset.blade.php index ce49920..1a47a29 100644 --- a/resources/views/mail/notifications/password-reset.blade.php +++ b/resources/views/mail/notifications/password-reset.blade.php @@ -2,7 +2,11 @@ Recuperá tu contraseña -@if($attempt->reason === 'account_locked') +@if($attempt->reason === \App\Domains\Auth\Models\ResetPasswordAttempt::REASON_STAFF_CREATED) +

+ Hola {{ $attempt->user->nombre_apellido }}, creamos tu cuenta de scanner en {{ $tenant->nombre }}. Utilizá este código para crear tu contraseña y comenzar a usarla. +

+@elseif($attempt->reason === \App\Domains\Auth\Models\ResetPasswordAttempt::REASON_ACCOUNT_LOCKED)

Hola {{ $attempt->user->nombre_apellido }}, registramos varios intentos fallidos de inicio de sesión en tu cuenta. Por seguridad, hemos bloqueado el acceso temporalmente. Puedes utilizar este código para cambiar tu contraseña y desbloquearla inmediatamente.

@@ -25,14 +29,16 @@
- Ingresar código ahora + {{ $attempt->reason === \App\Domains\Auth\Models\ResetPasswordAttempt::REASON_STAFF_CREATED ? 'Crear mi contraseña' : 'Ingresar código ahora' }}
@endif

-@if($attempt->reason === 'account_locked') +@if($attempt->reason === \App\Domains\Auth\Models\ResetPasswordAttempt::REASON_ACCOUNT_LOCKED) Si no fuiste vos, por favor desestimá y borrá este correo. Tu cuenta seguirá protegida. +@elseif($attempt->reason === \App\Domains\Auth\Models\ResetPasswordAttempt::REASON_STAFF_CREATED) + Si no esperabas recibir una cuenta de scanner, podés ignorar este mensaje. @else Si no solicitaste recuperar tu contraseña, podés ignorar este mensaje. @endif diff --git a/tests/Feature/Auth/ScannerLoginControllerTest.php b/tests/Feature/Auth/ScannerLoginControllerTest.php index 1c1437d..d6032ee 100644 --- a/tests/Feature/Auth/ScannerLoginControllerTest.php +++ b/tests/Feature/Auth/ScannerLoginControllerTest.php @@ -3,13 +3,16 @@ namespace Tests\Feature\Auth; use App\Domains\Auth\Models\LoginAttempt; +use App\Domains\Auth\Models\ResetPasswordAttempt; use App\Domains\Auth\Models\User; use App\Domains\Authorization\Enums\PermissionCode; use App\Domains\Authorization\Enums\RoleCode; use App\Domains\Authorization\Models\Permission; 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; @@ -113,4 +116,51 @@ class ScannerLoginControllerTest extends TestCase 'outcome' => LoginAttempt::OUTCOME_INVALID_CREDENTIALS, ]); } + + public function test_locking_a_scanner_sends_the_scanner_password_reset_flow(): void + { + Event::fake([PasswordResetRequested::class]); + config([ + 'login-security.max_attempts' => 3, + 'login-security.rate_limit_per_minute' => 100, + 'login-security.ip_rate_limit_per_minute' => 100, + ]); + $role = Role::query()->create([ + 'codigo' => RoleCode::Scanner->value, + 'nombre' => 'Scanner', + ]); + $permission = Permission::query()->create([ + 'codigo' => PermissionCode::ScanTickets->value, + 'nombre' => 'Escanear tickets', + ]); + $role->permissions()->attach($permission->codigo); + $tenant = Tenant::query()->create([ + 'codigo' => 'acme', + 'nombre' => 'Acme', + 'dominio' => 'acme.test', + ]); + $user = User::factory()->create([ + 'email' => 'scanner@example.com', + 'password' => Hash::make('correct-password'), + 'rol_codigo' => $role->codigo, + 'tenant_codigo' => $tenant->codigo, + ]); + $payload = [ + 'email' => $user->email, + 'password' => 'wrong-password', + ]; + + $this->postJson('/api/v1/scanner/login', $payload)->assertUnprocessable(); + $this->postJson('/api/v1/scanner/login', $payload)->assertUnprocessable(); + $this->postJson('/api/v1/scanner/login', $payload)->assertTooManyRequests(); + + $attempt = $user->resetPasswordAttempts()->sole(); + $this->assertSame(ResetPasswordAttempt::REASON_ACCOUNT_LOCKED, $attempt->reason); + Event::assertDispatched( + PasswordResetRequested::class, + fn (PasswordResetRequested $event): bool => $event->attemptId === $attempt->id + && $event->tenantCode === $tenant->codigo + && $event->channel === PasswordResetRequested::CHANNEL_SCANNER, + ); + } } diff --git a/tests/Feature/Notification/NotificationMailServiceTest.php b/tests/Feature/Notification/NotificationMailServiceTest.php index c79905b..78b303d 100644 --- a/tests/Feature/Notification/NotificationMailServiceTest.php +++ b/tests/Feature/Notification/NotificationMailServiceTest.php @@ -116,6 +116,7 @@ class NotificationMailServiceTest extends TestCase $this->tenant->update(['website_type_code' => $websiteType->codigo]); $attempt = $this->user->resetPasswordAttempts()->create([ 'codigo' => '0123', + 'reason' => ResetPasswordAttempt::REASON_STAFF_CREATED, ]); app(NotificationMailService::class)->sendPasswordResetCode( @@ -127,10 +128,10 @@ class NotificationMailServiceTest extends TestCase 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', - ); + return str_contains($rendered, 'https://scanner.mail.local/recuperar-contrasena/codigo') + && str_contains($rendered, 'email=ada%40example.com') + && str_contains($rendered, 'code=0123') + && str_contains($rendered, 'Crear mi'); }); } diff --git a/tests/Feature/Staff/StaffControllerTest.php b/tests/Feature/Staff/StaffControllerTest.php index 1192dc4..e7c9434 100644 --- a/tests/Feature/Staff/StaffControllerTest.php +++ b/tests/Feature/Staff/StaffControllerTest.php @@ -2,13 +2,16 @@ namespace Tests\Feature\Staff; +use App\Domains\Auth\Models\ResetPasswordAttempt; use App\Domains\Auth\Models\User; use App\Domains\Authorization\Enums\RoleCode; use App\Domains\Catalog\Models\Category; +use App\Domains\Notification\Events\PasswordResetRequested; use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\WebsiteType; use Database\Seeders\AuthorizationSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Event; use Laravel\Sanctum\Sanctum; use Tests\TestCase; @@ -24,6 +27,7 @@ class StaffControllerTest extends TestCase { parent::setUp(); + Event::fake([PasswordResetRequested::class]); $this->seed(AuthorizationSeeder::class); WebsiteType::query()->create(['codigo' => 'onticket', 'nombre' => 'OnTicket']); $this->tenant = Tenant::query()->create([ @@ -59,6 +63,16 @@ class StaffControllerTest extends TestCase 'user_id' => $staffId, 'categoria_id' => $firstCategory->id, ]); + $this->assertDatabaseHas('reset_password_attempts', [ + 'user_id' => $staffId, + 'reason' => ResetPasswordAttempt::REASON_STAFF_CREATED, + 'status' => ResetPasswordAttempt::STATUS_PENDING, + ]); + Event::assertDispatched( + PasswordResetRequested::class, + fn (PasswordResetRequested $event): bool => $event->tenantCode === $this->tenant->codigo + && $event->channel === PasswordResetRequested::CHANNEL_SCANNER, + ); $this->getJson('/api/v1/adminapp/tenant/staff?search=ada') ->assertOk()