diff --git a/app/Domains/Auth/Controllers/CreateAdminAppResetPasswordAttemptController.php b/app/Domains/Auth/Controllers/CreateAdminAppResetPasswordAttemptController.php new file mode 100644 index 0000000..8432948 --- /dev/null +++ b/app/Domains/Auth/Controllers/CreateAdminAppResetPasswordAttemptController.php @@ -0,0 +1,29 @@ +resetPasswordAttemptService->createForAdminAppEmail( + $request->validated('email'), + ); + + return response()->json([ + 'code' => 'auth.password_reset_requested', + 'message' => __('api.auth.password_reset_requested'), + 'status' => ResetPasswordAttempt::STATUS_PENDING, + ], 202); + } +} diff --git a/app/Domains/Auth/Requests/AdminAppCreateResetPasswordAttemptRequest.php b/app/Domains/Auth/Requests/AdminAppCreateResetPasswordAttemptRequest.php new file mode 100644 index 0000000..3a06c56 --- /dev/null +++ b/app/Domains/Auth/Requests/AdminAppCreateResetPasswordAttemptRequest.php @@ -0,0 +1,31 @@ +input('email'); + + if (is_string($email)) { + $this->merge(['email' => Str::lower(trim($email))]); + } + } + + /** @return array */ + public function rules(): array + { + return [ + 'email' => ['required', 'string', 'email', 'max:255'], + ]; + } +} diff --git a/app/Domains/Auth/Services/PasswordLoginService.php b/app/Domains/Auth/Services/PasswordLoginService.php index 42c76c5..a1bd28f 100644 --- a/app/Domains/Auth/Services/PasswordLoginService.php +++ b/app/Domains/Auth/Services/PasswordLoginService.php @@ -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, diff --git a/app/Domains/Auth/Services/ResetPasswordAttemptService.php b/app/Domains/Auth/Services/ResetPasswordAttemptService.php index 9721277..6d9e8bb 100644 --- a/app/Domains/Auth/Services/ResetPasswordAttemptService.php +++ b/app/Domains/Auth/Services/ResetPasswordAttemptService.php @@ -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); diff --git a/app/Domains/Auth/routes/adminapp.php b/app/Domains/Auth/routes/adminapp.php index d3cebbc..91e7e22 100644 --- a/app/Domains/Auth/routes/adminapp.php +++ b/app/Domains/Auth/routes/adminapp.php @@ -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); }); diff --git a/app/Domains/Notification/Events/PasswordResetRequested.php b/app/Domains/Notification/Events/PasswordResetRequested.php index df60117..a9d9e5f 100644 --- a/app/Domains/Notification/Events/PasswordResetRequested.php +++ b/app/Domains/Notification/Events/PasswordResetRequested.php @@ -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, ) {} } diff --git a/app/Domains/Notification/Listeners/SendPasswordResetEmail.php b/app/Domains/Notification/Listeners/SendPasswordResetEmail.php index e69745c..af7d8f3 100644 --- a/app/Domains/Notification/Listeners/SendPasswordResetEmail.php +++ b/app/Domains/Notification/Listeners/SendPasswordResetEmail.php @@ -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, ]); diff --git a/app/Domains/Notification/Services/NotificationMailService.php b/app/Domains/Notification/Services/NotificationMailService.php index 1b34200..e958705 100644 --- a/app/Domains/Notification/Services/NotificationMailService.php +++ b/app/Domains/Notification/Services/NotificationMailService.php @@ -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(), ); } diff --git a/resources/views/mail/notifications/password-reset.blade.php b/resources/views/mail/notifications/password-reset.blade.php index 0b9a955..ce49920 100644 --- a/resources/views/mail/notifications/password-reset.blade.php +++ b/resources/views/mail/notifications/password-reset.blade.php @@ -21,16 +21,14 @@ -@php - $recoveryUrl = 'https://' . $tenant->dominio . '/recuperar-contrasena/codigo?email=' . urlencode($attempt->user->email); -@endphp - +@if($recoveryUrl)
Ingresar código ahora
+@endif

@if($attempt->reason === 'account_locked') diff --git a/tests/Feature/Auth/AdminAppResetPasswordControllerTest.php b/tests/Feature/Auth/AdminAppResetPasswordControllerTest.php new file mode 100644 index 0000000..1e301a1 --- /dev/null +++ b/tests/Feature/Auth/AdminAppResetPasswordControllerTest.php @@ -0,0 +1,61 @@ +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); + } +} diff --git a/tests/Unit/Notification/SendPasswordResetEmailTest.php b/tests/Unit/Notification/SendPasswordResetEmailTest.php index cad33c5..f52035c 100644 --- a/tests/Unit/Notification/SendPasswordResetEmailTest.php +++ b/tests/Unit/Notification/SendPasswordResetEmailTest.php @@ -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), );