feat(auth): implement admin app password reset attempt functionality

This commit is contained in:
2026-08-13 12:15:14 -03:00
parent a2592987f5
commit e33ebf0af1
11 changed files with 283 additions and 43 deletions

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Domains\Auth\Controllers;
use App\Domains\Auth\Models\ResetPasswordAttempt;
use App\Domains\Auth\Requests\AdminAppCreateResetPasswordAttemptRequest;
use App\Domains\Auth\Services\ResetPasswordAttemptService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
class CreateAdminAppResetPasswordAttemptController extends Controller
{
public function __construct(
private readonly ResetPasswordAttemptService $resetPasswordAttemptService,
) {}
public function __invoke(AdminAppCreateResetPasswordAttemptRequest $request): JsonResponse
{
$this->resetPasswordAttemptService->createForAdminAppEmail(
$request->validated('email'),
);
return response()->json([
'code' => 'auth.password_reset_requested',
'message' => __('api.auth.password_reset_requested'),
'status' => ResetPasswordAttempt::STATUS_PENDING,
], 202);
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Domains\Auth\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
class AdminAppCreateResetPasswordAttemptRequest 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'],
];
}
}

View File

@@ -243,7 +243,18 @@ class PasswordLoginService
if ($attempts >= $maxAttempts && $previousAttempts < $maxAttempts) {
try {
$this->resetPasswordAttemptService->createForEmail($user->email, $tenantCode, 'account_locked');
if ($user->rol_codigo === RoleCode::AdminApp->value) {
$this->resetPasswordAttemptService->createForAdminAppEmail(
$user->email,
'account_locked',
);
} else {
$this->resetPasswordAttemptService->createForEmail(
$user->email,
$tenantCode,
'account_locked',
);
}
} catch (\Throwable $e) {
Log::error('Failed to trigger reset password on account lock', [
'user_id' => $user->id,

View File

@@ -4,6 +4,7 @@ namespace App\Domains\Auth\Services;
use App\Domains\Auth\Models\ResetPasswordAttempt;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Notification\Events\PasswordResetRequested;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
@@ -22,28 +23,12 @@ class ResetPasswordAttemptService
->lockForUpdate()
->first();
if ($user === null) {
Log::warning('Password reset attempt was not created because the user was not found.', [
'email_fingerprint' => $emailFingerprint,
]);
return null;
}
$user->resetPasswordAttempts()
->whereIn('status', [
ResetPasswordAttempt::STATUS_PENDING,
ResetPasswordAttempt::STATUS_VALIDATED,
])
->update(['status' => ResetPasswordAttempt::STATUS_EXPIRED]);
$attempt = $user->resetPasswordAttempts()->create([
'codigo' => $this->generateCode(),
'reason' => $reason,
'status' => ResetPasswordAttempt::STATUS_PENDING,
]);
return $attempt->getKey();
return $this->createAttemptForUser(
$user,
$reason,
$emailFingerprint,
'Password reset attempt was not created because the user was not found.',
);
});
} catch (Throwable $exception) {
Log::error('Failed to create password reset attempt.', [
@@ -54,20 +39,58 @@ class ResetPasswordAttemptService
throw $exception;
}
if ($attemptId !== null) {
try {
PasswordResetRequested::dispatch($attemptId, $tenantCode);
} catch (Throwable $exception) {
Log::error('Failed to dispatch password reset email.', [
'attempt_id' => $attemptId,
'tenant_code' => $tenantCode,
'email_fingerprint' => $emailFingerprint,
'exception' => $exception,
]);
$this->dispatchPasswordResetRequested(
$attemptId,
$tenantCode,
PasswordResetRequested::CHANNEL_STOREFRONT,
$emailFingerprint,
);
}
throw $exception;
}
public function createForAdminAppEmail(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::AdminApp->value)
->whereNotNull('tenant_codigo')
->lockForUpdate()
->first();
$attemptId = $this->createAttemptForUser(
$user,
$reason,
$emailFingerprint,
'AdminApp 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 AdminApp password reset attempt.', [
'email_fingerprint' => $emailFingerprint,
'exception' => $exception,
]);
throw $exception;
}
$this->dispatchPasswordResetRequested(
$result['attempt_id'] ?? null,
$result['tenant_code'] ?? null,
PasswordResetRequested::CHANNEL_ADMINAPP,
$emailFingerprint,
);
}
public function validateCode(string $email, string $code): bool
@@ -169,6 +192,61 @@ class ResetPasswordAttemptService
}
}
private function createAttemptForUser(
?User $user,
string $reason,
string $emailFingerprint,
string $userNotFoundMessage,
): ?int {
if ($user === null) {
Log::warning($userNotFoundMessage, [
'email_fingerprint' => $emailFingerprint,
]);
return null;
}
$user->resetPasswordAttempts()
->whereIn('status', [
ResetPasswordAttempt::STATUS_PENDING,
ResetPasswordAttempt::STATUS_VALIDATED,
])
->update(['status' => ResetPasswordAttempt::STATUS_EXPIRED]);
$attempt = $user->resetPasswordAttempts()->create([
'codigo' => $this->generateCode(),
'reason' => $reason,
'status' => ResetPasswordAttempt::STATUS_PENDING,
]);
return $attempt->getKey();
}
private function dispatchPasswordResetRequested(
?int $attemptId,
?string $tenantCode,
string $channel,
string $emailFingerprint,
): void {
if ($attemptId === null || $tenantCode === null) {
return;
}
try {
PasswordResetRequested::dispatch($attemptId, $tenantCode, $channel);
} catch (Throwable $exception) {
Log::error('Failed to dispatch password reset email.', [
'attempt_id' => $attemptId,
'tenant_code' => $tenantCode,
'channel' => $channel,
'email_fingerprint' => $emailFingerprint,
'exception' => $exception,
]);
throw $exception;
}
}
private function generateCode(): string
{
return str_pad((string) random_int(0, 9999), 4, '0', STR_PAD_LEFT);

View File

@@ -2,10 +2,19 @@
use App\Domains\Auth\Controllers\AdminAppLoginController;
use App\Domains\Auth\Controllers\AdminAppMeController;
use App\Domains\Auth\Controllers\CreateAdminAppResetPasswordAttemptController;
use App\Domains\Auth\Controllers\ResetPasswordController;
use App\Domains\Auth\Controllers\ValidateResetPasswordAttemptController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp')->group(function (): void {
Route::post('login', AdminAppLoginController::class)->middleware('throttle:login');
Route::post('password/reset-attempts', CreateAdminAppResetPasswordAttemptController::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', 'adminapp.tenant'])
->get('me', AdminAppMeController::class);
});

View File

@@ -9,8 +9,13 @@ class PasswordResetRequested
{
use Dispatchable, SerializesModels;
public const CHANNEL_STOREFRONT = 'storefront';
public const CHANNEL_ADMINAPP = 'adminapp';
public function __construct(
public readonly int $attemptId,
public readonly string $tenantCode,
public readonly string $channel = self::CHANNEL_STOREFRONT,
) {}
}

View File

@@ -26,11 +26,13 @@ class SendPasswordResetEmail implements ShouldQueueAfterCommit
app(NotificationMailService::class)->sendPasswordResetCode(
$event->attemptId,
$event->tenantCode,
$event->channel,
);
} catch (Throwable $exception) {
Log::error('Failed to send password reset email.', [
'attempt_id' => $event->attemptId,
'tenant_code' => $event->tenantCode,
'channel' => $event->channel,
'exception' => $exception,
]);

View File

@@ -4,6 +4,7 @@ namespace App\Domains\Notification\Services;
use App\Domains\Auth\Models\ResetPasswordAttempt;
use App\Domains\Auth\Models\User;
use App\Domains\Notification\Events\PasswordResetRequested;
use App\Domains\Integration\Services\MailService;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Tenant\Models\Tenant;
@@ -31,9 +32,16 @@ class NotificationMailService
);
}
public function sendPasswordResetCode(int $attemptId, string $tenantCode): void
public function sendPasswordResetCode(
int $attemptId,
string $tenantCode,
string $channel = PasswordResetRequested::CHANNEL_STOREFRONT,
): void
{
$tenant = Tenant::query()->where('codigo', $tenantCode)->firstOrFail();
$tenant = Tenant::query()
->with('websiteType')
->where('codigo', $tenantCode)
->firstOrFail();
$attempt = ResetPasswordAttempt::query()
->with('user')
->findOrFail($attemptId);
@@ -48,12 +56,19 @@ class NotificationMailService
return;
}
$recoveryDomain = $channel === PasswordResetRequested::CHANNEL_ADMINAPP
? $tenant->websiteType?->dominio
: $tenant->dominio;
$recoveryUrl = $recoveryDomain === null
? null
: 'https://'.$recoveryDomain.'/recuperar-contrasena/codigo?email='.urlencode($attempt->user->email);
$this->mailService
->forTenant($tenantCode)
->send(
$attempt->user->email,
"Código para recuperar tu contraseña - {$tenant->nombre}",
view('mail.notifications.password-reset', compact('tenant', 'attempt'))->render(),
view('mail.notifications.password-reset', compact('tenant', 'attempt', 'recoveryUrl'))->render(),
);
}

View File

@@ -21,16 +21,14 @@
</span>
</div>
@php
$recoveryUrl = 'https://' . $tenant->dominio . '/recuperar-contrasena/codigo?email=' . urlencode($attempt->user->email);
@endphp
@if($recoveryUrl)
<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
</a>
</div>
@endif
<p style="color: #64748b; font-size: 14px;">
@if($attempt->reason === 'account_locked')

View File

@@ -0,0 +1,61 @@
<?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 Tests\TestCase;
class AdminAppResetPasswordControllerTest extends TestCase
{
use RefreshDatabase;
public function test_it_creates_an_attempt_for_a_tenant_bound_adminapp_user(): void
{
Event::fake([PasswordResetRequested::class]);
Role::query()->create([
'codigo' => RoleCode::AdminApp->value,
'nombre' => 'AdminApp',
]);
$tenant = Tenant::query()->create([
'codigo' => 'acme',
'nombre' => 'Acme',
'dominio' => 'store.acme.test',
]);
$user = User::factory()->create([
'email' => 'admin@example.com',
'rol_codigo' => RoleCode::AdminApp->value,
'tenant_codigo' => $tenant->codigo,
]);
$this->postJson('/api/v1/adminapp/password/reset-attempts', [
'email' => ' ADMIN@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_ADMINAPP,
);
}
public function test_it_does_not_create_an_attempt_for_a_non_adminapp_user(): void
{
Event::fake([PasswordResetRequested::class]);
$this->postJson('/api/v1/adminapp/password/reset-attempts', [
'email' => User::factory()->create()->email,
])->assertAccepted()->assertJsonPath('status', ResetPasswordAttempt::STATUS_PENDING);
$this->assertDatabaseCount('reset_password_attempts', 0);
Event::assertNotDispatched(PasswordResetRequested::class);
}
}

View File

@@ -17,7 +17,7 @@ class SendPasswordResetEmailTest extends TestCase
$mailService = \Mockery::mock(NotificationMailService::class);
$mailService->shouldReceive('sendPasswordResetCode')
->once()
->with(10, 'tenant-test')
->with(10, 'tenant-test', PasswordResetRequested::CHANNEL_STOREFRONT)
->andThrow($exception);
$this->app->instance(NotificationMailService::class, $mailService);
@@ -27,6 +27,7 @@ class SendPasswordResetEmailTest extends TestCase
'Failed to send password reset email.',
\Mockery::on(fn (array $context): bool => $context['attempt_id'] === 10
&& $context['tenant_code'] === 'tenant-test'
&& $context['channel'] === PasswordResetRequested::CHANNEL_STOREFRONT
&& $context['exception'] === $exception),
);