emailFingerprint($email); try { $attemptId = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?int { $user = User::query() ->where('email', $email) ->lockForUpdate() ->first(); 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.', [ 'email_fingerprint' => $emailFingerprint, 'exception' => $exception, ]); throw $exception; } $this->dispatchPasswordResetRequested( $attemptId, $tenantCode, PasswordResetRequested::CHANNEL_STOREFRONT, $emailFingerprint, ); } public function createForAdminAppEmail( string $email, string $reason = ResetPasswordAttempt::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 createForScannerEmail( string $email, string $reason = ResetPasswordAttempt::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::Scanner->value) ->whereNotNull('tenant_codigo') ->lockForUpdate() ->first(); $attemptId = $this->createAttemptForUser( $user, $reason, $emailFingerprint, 'Scanner 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 Scanner password reset attempt.', [ 'email_fingerprint' => $emailFingerprint, 'exception' => $exception, ]); throw $exception; } $this->dispatchPasswordResetRequested( $result['attempt_id'] ?? null, $result['tenant_code'] ?? null, PasswordResetRequested::CHANNEL_SCANNER, $emailFingerprint, ); } 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->failed_login_attempts = 0; $user->last_failed_login_at = null; $user->locked_until = null; $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 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); } private function emailFingerprint(string $email): string { return substr(hash('sha256', strtolower(trim($email))), 0, 12); } }