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

This commit is contained in:
ncoronel 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'])]
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';

View File

@ -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,

View File

@ -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);

View File

@ -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)

View File

@ -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<int, User> */
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');
});

View File

@ -2,7 +2,11 @@
Recuperá tu contraseña
</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>
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>
@ -25,14 +29,16 @@
<div style="text-align: center; margin-bottom: 28px;">
<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;">
Ingresar código ahora
{{ $attempt->reason === \App\Domains\Auth\Models\ResetPasswordAttempt::REASON_STAFF_CREATED ? 'Crear mi contraseña' : 'Ingresar código ahora' }}
</a>
</div>
@endif
<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.
@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

View File

@ -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,
);
}
}

View File

@ -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');
});
}

View File

@ -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()