feat(auth): enhance password reset attempt handling with new reasons and update related services

This commit is contained in:
2026-08-13 16:49:19 -03:00
parent 630b49cad7
commit a4a4c9afbc
9 changed files with 149 additions and 23 deletions

View File

@@ -11,6 +11,12 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Hidden(['codigo'])] #[Hidden(['codigo'])]
class ResetPasswordAttempt extends Model 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_PENDING = 'pending';
public const STATUS_VALIDATED = 'validated'; public const STATUS_VALIDATED = 'validated';

View File

@@ -4,9 +4,11 @@ namespace App\Domains\Auth\Services;
use App\Domains\Auth\Exceptions\AccountLockedException; use App\Domains\Auth\Exceptions\AccountLockedException;
use App\Domains\Auth\Models\LoginAttempt; use App\Domains\Auth\Models\LoginAttempt;
use App\Domains\Auth\Models\ResetPasswordAttempt;
use App\Domains\Auth\Models\User; use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\PermissionCode; use App\Domains\Authorization\Enums\PermissionCode;
use App\Domains\Authorization\Enums\RoleCode; use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Notification\Events\PasswordResetRequested;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
@@ -60,6 +62,8 @@ class PasswordLoginService
$userAgent, $userAgent,
RoleCode::AdminApp, RoleCode::AdminApp,
true, true,
null,
PasswordResetRequested::CHANNEL_ADMINAPP,
); );
} }
@@ -84,6 +88,7 @@ class PasswordLoginService
null, null,
true, true,
PermissionCode::ScanTickets->value, PermissionCode::ScanTickets->value,
PasswordResetRequested::CHANNEL_SCANNER,
); );
} }
@@ -96,6 +101,7 @@ class PasswordLoginService
?RoleCode $requiredRole = RoleCode::User, ?RoleCode $requiredRole = RoleCode::User,
bool $requiresTenant = false, bool $requiresTenant = false,
?string $requiredPermission = null, ?string $requiredPermission = null,
string $passwordResetChannel = PasswordResetRequested::CHANNEL_STOREFRONT,
): User { ): User {
$normalizedEmail = mb_strtolower(trim($email)); $normalizedEmail = mb_strtolower(trim($email));
$now = CarbonImmutable::now(); $now = CarbonImmutable::now();
@@ -111,6 +117,7 @@ class PasswordLoginService
$requiredRole, $requiredRole,
$requiresTenant, $requiresTenant,
$requiredPermission, $requiredPermission,
$passwordResetChannel,
): array { ): array {
$user = User::query() $user = User::query()
->where('email', $normalizedEmail) ->where('email', $normalizedEmail)
@@ -160,7 +167,12 @@ class PasswordLoginService
if ($user === null || ! Hash::check($password, $user->password)) { if ($user === null || ! Hash::check($password, $user->password)) {
if ($user !== null && $attemptTenantCode !== null) { if ($user !== null && $attemptTenantCode !== null) {
$this->registerFailure($user, $now, $attemptTenantCode); $this->registerFailure(
$user,
$now,
$attemptTenantCode,
$passwordResetChannel,
);
} }
$outcome = $user?->locked_until?->isFuture() $outcome = $user?->locked_until?->isFuture()
@@ -219,7 +231,12 @@ class PasswordLoginService
return $result['user']; 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')); $windowMinutes = max(1, (int) config('login-security.attempt_window_minutes'));
$maxAttempts = max(1, (int) config('login-security.max_attempts')); $maxAttempts = max(1, (int) config('login-security.max_attempts'));
@@ -243,18 +260,23 @@ class PasswordLoginService
if ($attempts >= $maxAttempts && $previousAttempts < $maxAttempts) { if ($attempts >= $maxAttempts && $previousAttempts < $maxAttempts) {
try { try {
if ($user->rol_codigo === RoleCode::AdminApp->value) { match ($passwordResetChannel) {
$this->resetPasswordAttemptService->createForAdminAppEmail( PasswordResetRequested::CHANNEL_ADMINAPP =>
$user->email, $this->resetPasswordAttemptService->createForAdminAppEmail(
'account_locked', $user->email,
); ResetPasswordAttempt::REASON_ACCOUNT_LOCKED,
} else { ),
$this->resetPasswordAttemptService->createForEmail( PasswordResetRequested::CHANNEL_SCANNER =>
$this->resetPasswordAttemptService->createForScannerEmail(
$user->email,
ResetPasswordAttempt::REASON_ACCOUNT_LOCKED,
),
default => $this->resetPasswordAttemptService->createForEmail(
$user->email, $user->email,
$tenantCode, $tenantCode,
'account_locked', ResetPasswordAttempt::REASON_ACCOUNT_LOCKED,
); ),
} };
} 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,

View File

@@ -12,7 +12,11 @@ use Throwable;
class ResetPasswordAttemptService 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); $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); $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); $emailFingerprint = $this->emailFingerprint($email);

View File

@@ -61,9 +61,16 @@ class NotificationMailService
PasswordResetRequested::CHANNEL_SCANNER => $tenant->websiteType?->scanner_domain, PasswordResetRequested::CHANNEL_SCANNER => $tenant->websiteType?->scanner_domain,
default => $tenant->dominio, 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 $recoveryUrl = $recoveryDomain === null
? null ? null
: 'https://'.$recoveryDomain.'/recuperar-contrasena/codigo?email='.urlencode($attempt->user->email); : 'https://'.$recoveryDomain.'/recuperar-contrasena/codigo?'.http_build_query($recoveryQuery);
$this->mailService $this->mailService
->forTenant($tenantCode) ->forTenant($tenantCode)

View File

@@ -2,7 +2,9 @@
namespace App\Domains\Staff\Services; namespace App\Domains\Staff\Services;
use App\Domains\Auth\Models\ResetPasswordAttempt;
use App\Domains\Auth\Models\User; use App\Domains\Auth\Models\User;
use App\Domains\Auth\Services\ResetPasswordAttemptService;
use App\Domains\Authorization\Enums\RoleCode; use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Catalog\Models\Category; use App\Domains\Catalog\Models\Category;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
@@ -15,6 +17,10 @@ use Illuminate\Validation\ValidationException;
class StaffService class StaffService
{ {
public function __construct(
private readonly ResetPasswordAttemptService $resetPasswordAttemptService,
) {}
/** @return Collection<int, User> */ /** @return Collection<int, User> */
public function list(Tenant $tenant, ?string $search = null): Collection public function list(Tenant $tenant, ?string $search = null): Collection
{ {
@@ -60,6 +66,10 @@ class StaffService
'tenant_codigo' => $tenant->codigo, 'tenant_codigo' => $tenant->codigo,
]); ]);
$staff->scanCategories()->sync($categoryIds); $staff->scanCategories()->sync($categoryIds);
$this->resetPasswordAttemptService->createForScannerEmail(
$staff->email,
ResetPasswordAttempt::REASON_STAFF_CREATED,
);
return $staff->load('role', 'scanCategories'); return $staff->load('role', 'scanCategories');
}); });

View File

@@ -2,7 +2,11 @@
Recuperá tu contraseña Recuperá tu contraseña
</h1> </h1>
@if($attempt->reason === 'account_locked') @if($attempt->reason === \App\Domains\Auth\Models\ResetPasswordAttempt::REASON_STAFF_CREATED)
<p>
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.
</p>
@elseif($attempt->reason === \App\Domains\Auth\Models\ResetPasswordAttempt::REASON_ACCOUNT_LOCKED)
<p> <p>
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. 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.
</p> </p>
@@ -25,14 +29,16 @@
<div style="text-align: center; margin-bottom: 28px;"> <div style="text-align: center; margin-bottom: 28px;">
<a href="{{ $recoveryUrl }}" <a href="{{ $recoveryUrl }}"
style="display: inline-block; padding: 12px 24px; background-color: {{ $tenant->primary_color }}; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: bold;"> style="display: inline-block; padding: 12px 24px; background-color: {{ $tenant->primary_color }}; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: bold;">
Ingresar código ahora {{ $attempt->reason === \App\Domains\Auth\Models\ResetPasswordAttempt::REASON_STAFF_CREATED ? 'Crear mi contraseña' : 'Ingresar código ahora' }}
</a> </a>
</div> </div>
@endif @endif
<p style="color: #64748b; font-size: 14px;"> <p style="color: #64748b; font-size: 14px;">
@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. 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 @else
Si no solicitaste recuperar tu contraseña, podés ignorar este mensaje. Si no solicitaste recuperar tu contraseña, podés ignorar este mensaje.
@endif @endif

View File

@@ -3,13 +3,16 @@
namespace Tests\Feature\Auth; namespace Tests\Feature\Auth;
use App\Domains\Auth\Models\LoginAttempt; use App\Domains\Auth\Models\LoginAttempt;
use App\Domains\Auth\Models\ResetPasswordAttempt;
use App\Domains\Auth\Models\User; use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\PermissionCode; use App\Domains\Authorization\Enums\PermissionCode;
use App\Domains\Authorization\Enums\RoleCode; use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Authorization\Models\Permission; use App\Domains\Authorization\Models\Permission;
use App\Domains\Authorization\Models\Role; use App\Domains\Authorization\Models\Role;
use App\Domains\Notification\Events\PasswordResetRequested;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Tests\TestCase; use Tests\TestCase;
@@ -113,4 +116,51 @@ class ScannerLoginControllerTest extends TestCase
'outcome' => LoginAttempt::OUTCOME_INVALID_CREDENTIALS, '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,
);
}
} }

View File

@@ -116,6 +116,7 @@ class NotificationMailServiceTest extends TestCase
$this->tenant->update(['website_type_code' => $websiteType->codigo]); $this->tenant->update(['website_type_code' => $websiteType->codigo]);
$attempt = $this->user->resetPasswordAttempts()->create([ $attempt = $this->user->resetPasswordAttempts()->create([
'codigo' => '0123', 'codigo' => '0123',
'reason' => ResetPasswordAttempt::REASON_STAFF_CREATED,
]); ]);
app(NotificationMailService::class)->sendPasswordResetCode( app(NotificationMailService::class)->sendPasswordResetCode(
@@ -127,10 +128,10 @@ class NotificationMailServiceTest extends TestCase
Mail::assertSent(Mailable::class, function (Mailable $mail): bool { Mail::assertSent(Mailable::class, function (Mailable $mail): bool {
$rendered = $mail->render(); $rendered = $mail->render();
return str_contains( return str_contains($rendered, 'https://scanner.mail.local/recuperar-contrasena/codigo')
$rendered, && str_contains($rendered, 'email=ada%40example.com')
'https://scanner.mail.local/recuperar-contrasena/codigo?email=ada%40example.com', && str_contains($rendered, 'code=0123')
); && str_contains($rendered, 'Crear mi');
}); });
} }

View File

@@ -2,13 +2,16 @@
namespace Tests\Feature\Staff; namespace Tests\Feature\Staff;
use App\Domains\Auth\Models\ResetPasswordAttempt;
use App\Domains\Auth\Models\User; use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode; use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Catalog\Models\Category; use App\Domains\Catalog\Models\Category;
use App\Domains\Notification\Events\PasswordResetRequested;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\WebsiteType; use App\Domains\Tenant\Models\WebsiteType;
use Database\Seeders\AuthorizationSeeder; use Database\Seeders\AuthorizationSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Laravel\Sanctum\Sanctum; use Laravel\Sanctum\Sanctum;
use Tests\TestCase; use Tests\TestCase;
@@ -24,6 +27,7 @@ class StaffControllerTest extends TestCase
{ {
parent::setUp(); parent::setUp();
Event::fake([PasswordResetRequested::class]);
$this->seed(AuthorizationSeeder::class); $this->seed(AuthorizationSeeder::class);
WebsiteType::query()->create(['codigo' => 'onticket', 'nombre' => 'OnTicket']); WebsiteType::query()->create(['codigo' => 'onticket', 'nombre' => 'OnTicket']);
$this->tenant = Tenant::query()->create([ $this->tenant = Tenant::query()->create([
@@ -59,6 +63,16 @@ class StaffControllerTest extends TestCase
'user_id' => $staffId, 'user_id' => $staffId,
'categoria_id' => $firstCategory->id, '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') $this->getJson('/api/v1/adminapp/tenant/staff?search=ada')
->assertOk() ->assertOk()