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) { if ($attempts >= $maxAttempts && $previousAttempts < $maxAttempts) {
try { 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) { } 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

@@ -4,6 +4,7 @@ namespace App\Domains\Auth\Services;
use App\Domains\Auth\Models\ResetPasswordAttempt; 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\Notification\Events\PasswordResetRequested; use App\Domains\Notification\Events\PasswordResetRequested;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@@ -22,28 +23,12 @@ class ResetPasswordAttemptService
->lockForUpdate() ->lockForUpdate()
->first(); ->first();
if ($user === null) { return $this->createAttemptForUser(
Log::warning('Password reset attempt was not created because the user was not found.', [ $user,
'email_fingerprint' => $emailFingerprint, $reason,
]); $emailFingerprint,
'Password reset attempt was not created because the user was not found.',
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();
}); });
} catch (Throwable $exception) { } catch (Throwable $exception) {
Log::error('Failed to create password reset attempt.', [ Log::error('Failed to create password reset attempt.', [
@@ -54,20 +39,58 @@ class ResetPasswordAttemptService
throw $exception; throw $exception;
} }
if ($attemptId !== null) { $this->dispatchPasswordResetRequested(
try { $attemptId,
PasswordResetRequested::dispatch($attemptId, $tenantCode); $tenantCode,
} catch (Throwable $exception) { PasswordResetRequested::CHANNEL_STOREFRONT,
Log::error('Failed to dispatch password reset email.', [ $emailFingerprint,
'attempt_id' => $attemptId, );
'tenant_code' => $tenantCode, }
'email_fingerprint' => $emailFingerprint,
'exception' => $exception,
]);
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 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 private function generateCode(): string
{ {
return str_pad((string) random_int(0, 9999), 4, '0', STR_PAD_LEFT); 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\AdminAppLoginController;
use App\Domains\Auth\Controllers\AdminAppMeController; 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; use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp')->group(function (): void { Route::prefix('v1/adminapp')->group(function (): void {
Route::post('login', AdminAppLoginController::class)->middleware('throttle:login'); 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']) Route::middleware(['auth:sanctum', 'adminapp.tenant'])
->get('me', AdminAppMeController::class); ->get('me', AdminAppMeController::class);
}); });

View File

@@ -9,8 +9,13 @@ class PasswordResetRequested
{ {
use Dispatchable, SerializesModels; use Dispatchable, SerializesModels;
public const CHANNEL_STOREFRONT = 'storefront';
public const CHANNEL_ADMINAPP = 'adminapp';
public function __construct( public function __construct(
public readonly int $attemptId, public readonly int $attemptId,
public readonly string $tenantCode, 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( app(NotificationMailService::class)->sendPasswordResetCode(
$event->attemptId, $event->attemptId,
$event->tenantCode, $event->tenantCode,
$event->channel,
); );
} catch (Throwable $exception) { } catch (Throwable $exception) {
Log::error('Failed to send password reset email.', [ Log::error('Failed to send password reset email.', [
'attempt_id' => $event->attemptId, 'attempt_id' => $event->attemptId,
'tenant_code' => $event->tenantCode, 'tenant_code' => $event->tenantCode,
'channel' => $event->channel,
'exception' => $exception, 'exception' => $exception,
]); ]);

View File

@@ -4,6 +4,7 @@ namespace App\Domains\Notification\Services;
use App\Domains\Auth\Models\ResetPasswordAttempt; use App\Domains\Auth\Models\ResetPasswordAttempt;
use App\Domains\Auth\Models\User; use App\Domains\Auth\Models\User;
use App\Domains\Notification\Events\PasswordResetRequested;
use App\Domains\Integration\Services\MailService; use App\Domains\Integration\Services\MailService;
use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Models\Purchase;
use App\Domains\Tenant\Models\Tenant; 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() $attempt = ResetPasswordAttempt::query()
->with('user') ->with('user')
->findOrFail($attemptId); ->findOrFail($attemptId);
@@ -48,12 +56,19 @@ class NotificationMailService
return; 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 $this->mailService
->forTenant($tenantCode) ->forTenant($tenantCode)
->send( ->send(
$attempt->user->email, $attempt->user->email,
"Código para recuperar tu contraseña - {$tenant->nombre}", "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> </span>
</div> </div>
@php @if($recoveryUrl)
$recoveryUrl = 'https://' . $tenant->dominio . '/recuperar-contrasena/codigo?email=' . urlencode($attempt->user->email);
@endphp
<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 Ingresar código ahora
</a> </a>
</div> </div>
@endif
<p style="color: #64748b; font-size: 14px;"> <p style="color: #64748b; font-size: 14px;">
@if($attempt->reason === 'account_locked') @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 = \Mockery::mock(NotificationMailService::class);
$mailService->shouldReceive('sendPasswordResetCode') $mailService->shouldReceive('sendPasswordResetCode')
->once() ->once()
->with(10, 'tenant-test') ->with(10, 'tenant-test', PasswordResetRequested::CHANNEL_STOREFRONT)
->andThrow($exception); ->andThrow($exception);
$this->app->instance(NotificationMailService::class, $mailService); $this->app->instance(NotificationMailService::class, $mailService);
@@ -27,6 +27,7 @@ class SendPasswordResetEmailTest extends TestCase
'Failed to send password reset email.', 'Failed to send password reset email.',
\Mockery::on(fn (array $context): bool => $context['attempt_id'] === 10 \Mockery::on(fn (array $context): bool => $context['attempt_id'] === 10
&& $context['tenant_code'] === 'tenant-test' && $context['tenant_code'] === 'tenant-test'
&& $context['channel'] === PasswordResetRequested::CHANNEL_STOREFRONT
&& $context['exception'] === $exception), && $context['exception'] === $exception),
); );