Forgot password flow

This commit is contained in:
2026-07-27 09:43:00 -03:00
parent 7b565e7fa7
commit c37b8894e4
23 changed files with 1095 additions and 0 deletions

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Domains\Auth\Controllers;
use App\Domains\Auth\Models\ResetPasswordAttempt;
use App\Domains\Auth\Requests\CreateResetPasswordAttemptRequest;
use App\Domains\Auth\Services\ResetPasswordAttemptService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
class CreateResetPasswordAttemptController extends Controller
{
public function __construct(
private readonly ResetPasswordAttemptService $resetPasswordAttemptService,
) {}
public function __invoke(CreateResetPasswordAttemptRequest $request): JsonResponse
{
$data = $request->validated();
$this->resetPasswordAttemptService->createForEmail(
$data['email'],
$data['tenant_codigo'],
);
return response()->json([
'message' => 'Si el email está registrado, recibirás un código para recuperar tu contraseña.',
'status' => ResetPasswordAttempt::STATUS_PENDING,
], 202);
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Domains\Auth\Controllers;
use App\Domains\Auth\Models\ResetPasswordAttempt;
use App\Domains\Auth\Requests\ResetPasswordRequest;
use App\Domains\Auth\Services\ResetPasswordAttemptService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Validation\ValidationException;
class ResetPasswordController extends Controller
{
public function __construct(
private readonly ResetPasswordAttemptService $resetPasswordAttemptService,
) {}
/**
* @throws ValidationException
*/
public function __invoke(ResetPasswordRequest $request): JsonResponse
{
$data = $request->validated();
if (! $this->resetPasswordAttemptService->resetPassword(
$data['email'],
$data['codigo'],
$data['password'],
)) {
throw ValidationException::withMessages([
'codigo' => 'La solicitud de recuperación es inválida o ya fue utilizada.',
]);
}
return response()->json([
'message' => 'Contraseña modificada correctamente.',
'status' => ResetPasswordAttempt::STATUS_USED,
]);
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Domains\Auth\Controllers;
use App\Domains\Auth\Models\ResetPasswordAttempt;
use App\Domains\Auth\Requests\ValidateResetPasswordAttemptRequest;
use App\Domains\Auth\Services\ResetPasswordAttemptService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Validation\ValidationException;
class ValidateResetPasswordAttemptController extends Controller
{
public function __construct(
private readonly ResetPasswordAttemptService $resetPasswordAttemptService,
) {}
/**
* @throws ValidationException
*/
public function __invoke(ValidateResetPasswordAttemptRequest $request): JsonResponse
{
$data = $request->validated();
if (! $this->resetPasswordAttemptService->validateCode(
$data['email'],
$data['codigo'],
)) {
throw ValidationException::withMessages([
'codigo' => 'El código ingresado es inválido.',
]);
}
return response()->json([
'message' => 'Código validado correctamente.',
'status' => ResetPasswordAttempt::STATUS_VALIDATED,
]);
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Domains\Auth\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['user_id', 'codigo', 'status'])]
#[Hidden(['codigo'])]
class ResetPasswordAttempt extends Model
{
public const STATUS_PENDING = 'pending';
public const STATUS_VALIDATED = 'validated';
public const STATUS_USED = 'used';
public const STATUS_EXPIRED = 'expired';
public $timestamps = false;
protected function casts(): array
{
return [
'user_id' => 'integer',
];
}
/** @return BelongsTo<User, $this> */
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -6,6 +6,7 @@ use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
@@ -22,6 +23,12 @@ class User extends Authenticatable
return UserFactory::new();
}
/** @return HasMany<ResetPasswordAttempt, $this> */
public function resetPasswordAttempts(): HasMany
{
return $this->hasMany(ResetPasswordAttempt::class);
}
/**
* @return array<string, string>
*/

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Domains\Auth\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
class CreateResetPasswordAttemptRequest 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 [
'tenant_codigo' => ['required', 'string', Rule::exists('tenants', 'codigo')],
'email' => ['required', 'string', 'email', 'max:255'],
];
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Domains\Auth\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Password;
class ResetPasswordRequest 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'],
'codigo' => ['required', 'string', 'regex:/^\d{4}$/'],
'password' => [
'required',
'string',
'confirmed',
Password::min(8)->mixedCase()->symbols(),
],
];
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Domains\Auth\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
class ValidateResetPasswordAttemptRequest 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'],
'codigo' => ['required', 'string', 'regex:/^\d{4}$/'],
];
}
}

View File

@@ -0,0 +1,177 @@
<?php
namespace App\Domains\Auth\Services;
use App\Domains\Auth\Models\ResetPasswordAttempt;
use App\Domains\Auth\Models\User;
use App\Domains\Notification\Events\PasswordResetRequested;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;
class ResetPasswordAttemptService
{
public function createForEmail(string $email, string $tenantCode): void
{
$emailFingerprint = $this->emailFingerprint($email);
try {
$attemptId = DB::transaction(function () use ($email, $emailFingerprint): ?int {
$user = User::query()
->where('email', $email)
->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(),
'status' => ResetPasswordAttempt::STATUS_PENDING,
]);
return $attempt->getKey();
});
} catch (Throwable $exception) {
Log::error('Failed to create password reset attempt.', [
'email_fingerprint' => $emailFingerprint,
'exception' => $exception,
]);
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,
]);
throw $exception;
}
}
}
public function validateCode(string $email, string $code): bool
{
$emailFingerprint = $this->emailFingerprint($email);
try {
return DB::transaction(function () use ($email, $code, $emailFingerprint): bool {
$user = User::query()
->where('email', $email)
->lockForUpdate()
->first();
$attempt = $user?->resetPasswordAttempts()
->where('codigo', $code)
->where('status', ResetPasswordAttempt::STATUS_PENDING)
->latest('id')
->lockForUpdate()
->first();
if ($attempt === null) {
Log::warning('Password reset code validation failed: no matching pending attempt.', [
'email_fingerprint' => $emailFingerprint,
]);
return false;
}
$attempt->update([
'status' => ResetPasswordAttempt::STATUS_VALIDATED,
]);
return true;
});
} catch (Throwable $exception) {
Log::error('Failed to validate password reset code.', [
'email_fingerprint' => $emailFingerprint,
'exception' => $exception,
]);
throw $exception;
}
}
public function resetPassword(string $email, string $code, string $password): bool
{
$emailFingerprint = $this->emailFingerprint($email);
try {
return DB::transaction(function () use ($email, $code, $password, $emailFingerprint): bool {
$user = User::query()
->where('email', $email)
->lockForUpdate()
->first();
$attempt = $user?->resetPasswordAttempts()
->where('codigo', $code)
->where('status', ResetPasswordAttempt::STATUS_VALIDATED)
->latest('id')
->lockForUpdate()
->first();
if ($user === null || $attempt === null) {
Log::warning('Password reset failed: no matching validated attempt.', [
'email_fingerprint' => $emailFingerprint,
]);
return false;
}
$user->password = $password;
$user->save();
$user->tokens()->delete();
$user->resetPasswordAttempts()
->whereKeyNot($attempt->getKey())
->whereIn('status', [
ResetPasswordAttempt::STATUS_PENDING,
ResetPasswordAttempt::STATUS_VALIDATED,
])
->update(['status' => ResetPasswordAttempt::STATUS_EXPIRED]);
$attempt->update([
'status' => ResetPasswordAttempt::STATUS_USED,
]);
return true;
});
} catch (Throwable $exception) {
Log::error('Failed to reset user password.', [
'email_fingerprint' => $emailFingerprint,
'exception' => $exception,
]);
throw $exception;
}
}
private function generateCode(): string
{
return str_pad((string) random_int(0, 9999), 4, '0', STR_PAD_LEFT);
}
private function emailFingerprint(string $email): string
{
return substr(hash('sha256', strtolower(trim($email))), 0, 12);
}
}

View File

@@ -1,15 +1,24 @@
<?php
use App\Domains\Auth\Controllers\CreateResetPasswordAttemptController;
use App\Domains\Auth\Controllers\GoogleTokenExchangeController;
use App\Domains\Auth\Controllers\LoginController;
use App\Domains\Auth\Controllers\LogoutController;
use App\Domains\Auth\Controllers\MeController;
use App\Domains\Auth\Controllers\RegisterController;
use App\Domains\Auth\Controllers\ResetPasswordController;
use App\Domains\Auth\Controllers\UpdateProfileController;
use App\Domains\Auth\Controllers\ValidateResetPasswordAttemptController;
use Illuminate\Support\Facades\Route;
Route::post('/register', RegisterController::class);
Route::post('/login', LoginController::class);
Route::post('/password/reset-attempts', CreateResetPasswordAttemptController::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::post('/auth/google/exchange', GoogleTokenExchangeController::class);
Route::middleware('auth:sanctum')->post('/logout', LogoutController::class);
Route::middleware('auth:sanctum')->get('/me', MeController::class);

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Domains\Notification\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class PasswordResetRequested
{
use Dispatchable, SerializesModels;
public function __construct(
public readonly int $attemptId,
public readonly string $tenantCode,
) {}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Domains\Notification\Listeners;
use App\Domains\Notification\Events\PasswordResetRequested;
use App\Domains\Notification\Services\NotificationMailService;
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Log;
use Throwable;
class SendPasswordResetEmail implements ShouldQueueAfterCommit
{
use InteractsWithQueue;
public string $queue = 'emails';
public int $tries = 3;
/** @var array<int, int> */
public array $backoff = [30, 120, 300];
public function handle(PasswordResetRequested $event): void
{
try {
app(NotificationMailService::class)->sendPasswordResetCode(
$event->attemptId,
$event->tenantCode,
);
} catch (Throwable $exception) {
Log::error('Failed to send password reset email.', [
'attempt_id' => $event->attemptId,
'tenant_code' => $event->tenantCode,
'exception' => $exception,
]);
throw $exception;
}
}
}

View File

@@ -2,12 +2,14 @@
namespace App\Domains\Notification\Services;
use App\Domains\Auth\Models\ResetPasswordAttempt;
use App\Domains\Auth\Models\User;
use App\Domains\Integration\Services\MailService;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
class NotificationMailService
{
@@ -29,6 +31,32 @@ class NotificationMailService
);
}
public function sendPasswordResetCode(int $attemptId, string $tenantCode): void
{
$tenant = Tenant::query()->where('codigo', $tenantCode)->firstOrFail();
$attempt = ResetPasswordAttempt::query()
->with('user')
->findOrFail($attemptId);
if ($attempt->status !== ResetPasswordAttempt::STATUS_PENDING) {
Log::warning('Password reset email was skipped because the attempt is no longer pending.', [
'attempt_id' => $attemptId,
'tenant_code' => $tenantCode,
'attempt_status' => $attempt->status,
]);
return;
}
$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(),
);
}
public function sendPurchasePaid(int $purchaseId): void
{
$purchase = Purchase::query()

View File

@@ -2,8 +2,10 @@
namespace App\Providers;
use App\Domains\Notification\Events\PasswordResetRequested;
use App\Domains\Notification\Events\TicketsAvailable;
use App\Domains\Notification\Events\UserRegistered;
use App\Domains\Notification\Listeners\SendPasswordResetEmail;
use App\Domains\Notification\Listeners\SendPurchasePaidEmail;
use App\Domains\Notification\Listeners\SendTicketsAvailableEmail;
use App\Domains\Notification\Listeners\SendWelcomeEmail;
@@ -32,6 +34,7 @@ class AppServiceProvider extends ServiceProvider
Event::listen(PurchasePaid::class, GenerateTicketsForPaidPurchase::class);
Event::listen(TicketsAvailable::class, SendTicketsAvailableEmail::class);
Event::listen(UserRegistered::class, SendWelcomeEmail::class);
Event::listen(PasswordResetRequested::class, SendPasswordResetEmail::class);
Builder::macro('paginateFromRequest', function (int $defaultPerPage = 15, int $maxPerPage = 100, ?int $page = null) {
/** @var Builder $this */