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