domingo, 13 de julio de 2025

Login con Socialite y tradicional (parte 3), reseteo de contraseña

 En la parte anterior vimos cómo gestionar usuarios con emails duplicados, uno con login tradicional y uno o más con login de Socialite.

Ahora vamos a ver cómo se acota la función del reseteo de contraseña, únicamente a usuarios de login tradicional, lo cual significa excluir a usuarios de Socialite. Prestar atención porque la solución tiene 2 partes. La primera intencionalmente es incompleta, y la segunda ya corrige y completa todo lo necesario para que realmente funcione.

Vamos a comenzar con el siguiente planteo:

La fachada Illuminate\Support\Facades\Password tiene un método sendResetLink que sirve para enviar un email de reseteo de contraseña. Si un usuario/email está repetido, una vez sin provider (usuario de login tradicional) y otra vez con provider (login con Socialite), necesito evitar que la función del email con el link se aplique al usuario de login con Socialite.

Aquí tienes la solución para evitar que el enlace de reseteo de contraseña se envíe a usuarios de Socialite, incluso si comparten el mismo email con un usuario tradicional.

La clave es interceptar la solicitud de reseteo de contraseña antes de que se llame a Password::sendResetLink y realizar una verificación manual para asegurar que el email pertenece a un usuario tradicional (con provider nulo).


Modificación del Controlador de Envío de Enlace de Reseteo

El controlador que maneja el envío del enlace de reseteo de contraseña es típicamente app/Http/Controllers/Auth/PasswordResetLinkController.php si estás usando Laravel Breeze o Jetstream.

Vamos a modificar el método store de este controlador:

PHP
<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use App\Models\User; // Importa tu modelo User
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
use Illuminate\Validation\ValidationException;

class PasswordResetLinkController extends Controller
{
    /**
     * Display the password reset link request view.
     */
    public function create(): \Illuminate\View\View
    {
        return view('auth.forgot-password');
    }

    /**
     * Handle the incoming password reset link request.
     *
     * @throws \Illuminate\Validation\ValidationException
     */
    public function store(Request $request): RedirectResponse
    {
        $request->validate([
            'email' => ['required', 'email'],
        ]);

        // PASO CLAVE: Buscar el usuario por email, pero solo si es una cuenta tradicional.
        $user = User::where('email', $request->email)
                    ->whereNull('provider') // <-- ¡Aquí está la restricción!
                    ->first();

        // Si no se encuentra un usuario tradicional con ese email,
        // o si el email existe pero pertenece a una cuenta de Socialite,
        // devolvemos un mensaje de error que no revela la existencia del email
        // pero guía al usuario.
        if (!$user) {
            throw ValidationException::withMessages([
                'email' => __('passwords.user'), // Mensaje genérico de Laravel para "no encontramos un usuario con ese email"
                // O un mensaje más específico si quieres:
                // 'email' => 'No podemos encontrar un usuario con esa dirección de correo electrónico o la cuenta está asociada a un inicio de sesión social.',
            ]);
        }

        // Si se encontró un usuario tradicional, procedemos con el envío del enlace de reseteo.
        $status = Password::sendResetLink(
            $request->only('email')
        );

        if ($status === Password::RESET_LINK_SENT) {
            return back()->with('status', __($status));
        }

        throw ValidationException::withMessages([
            'email' => trans($status),
        ]);
    }
}

Explicación del Cambio:

  1. Búsqueda Condicional del Usuario:

    • Antes de llamar a Password::sendResetLink(), realizamos una consulta manual a tu modelo User.

    • La condición clave es ->whereNull('provider'). Esto asegura que solo busquemos y consideremos a los usuarios que se registraron de forma tradicional (es decir, aquellos que tienen un provider nulo, lo que significa que no usaron Socialite).

  2. Manejo de Errores:

    • Si la consulta $user = User::where('email', $request->email)->whereNull('provider')->first(); no devuelve ningún usuario (es decir, el email no existe en tu base de datos para un usuario tradicional, o existe pero es para una cuenta de Socialite), entonces sabemos que no debemos enviar un enlace de reseteo de contraseña tradicional.

    • En este caso, lanzamos una ValidationException con el mensaje __('passwords.user'). Este es un mensaje genérico de Laravel (que puedes personalizar en lang/es/passwords.php) que dice algo como "No podemos encontrar un usuario con esa dirección de correo electrónico". Es una buena práctica de seguridad no revelar si el email existe o no, pero en este contexto, también implica que la cuenta no es de tipo "contraseña".

  3. Llamada a Password::sendResetLink():

    • Solo si user es encontrado (y, por nuestra lógica, sabemos que es un usuario tradicional), procedemos a llamar a Password::sendResetLink(). Este método de la fachada Password hará el trabajo de generar el token, guardarlo y enviar el email.

¿Por qué este enfoque es viable y correcto?

  • No se modifica el núcleo de Laravel: No alteramos el comportamiento interno de Password::sendResetLink(). Simplemente controlamos cuándo se llama.

  • Cumple tu regla de negocio: Asegura que solo los usuarios con un provider nulo (es decir, los que tienen una contraseña gestionada por tu aplicación) puedan recibir un enlace de reseteo de contraseña.

  • Seguridad: Evita enviar enlaces de reseteo a emails que podrían estar asociados a cuentas de Socialite, lo cual no solo sería inútil (porque no tienen contraseña) sino que podría ser confuso o incluso un vector de ataque si se maneja incorrectamente.

  • Mensajes claros: Permite dar un feedback adecuado al usuario.

Con esta modificación, tu funcionalidad de reseteo de contraseña estará alineada con tu lógica de negocio de autenticación dual.


No está bien. Si dos usuarios tienen el mismo email, uno de login tradicional (provider nulo) y otro de Socialite (provider no nulo), mi requerimiento es que el token se genere exactamente para el usuario de login tradicional. Con la solución que me mostraste, este requerimiento no queda garantizado.

¡Tienes toda la razón! Mi solución anterior, al seguir usando Password::sendResetLink(), no garantizaba que, en caso de emails duplicados, el token se generara específicamente para el usuario con provider nulo. Password::sendResetLink() realiza su propia búsqueda interna, y no podemos controlar ese filtro.

Para garantizar este requerimiento, necesitamos tomar el control total del proceso de generación del token y envío del email. Esto significa:

  1. Buscar explícitamente al usuario tradicional (email + provider IS NULL).

  2. Generar el token de reseteo de contraseña manualmente para ese usuario específico.

  3. Enviar el email de reseteo manualmente a ese usuario específico, pasándole el token generado.

Así es como lo haríamos:


Modificación del Controlador de Envío de Enlace de Reseteo

Vamos a modificar el método store del controlador app/Http/Controllers/Auth/PasswordResetLinkController.php.

Controlador PasswordResetLinkController (Modificado)
<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use App\Models\User; // Importa tu modelo User
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
use Illuminate\Validation\ValidationException;
use Illuminate\Support\Facades\Notification; // Importa la fachada de Notificaciones
use Illuminate\Auth\Notifications\ResetPassword as ResetPasswordNotification; // Importa la notificación de reseteo de Laravel

class PasswordResetLinkController extends Controller
{
/**
* Display the password reset link request view.
*/
public function create(): \Illuminate\View\View
{
return view('auth.forgot-password');
}

/**
* Handle the incoming password reset link request.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'email' => ['required', 'email'],
]);

// PASO CLAVE: Buscar al usuario TRADICIONAL con el email proporcionado.
// Esto garantiza que solo un usuario con provider NULO (login tradicional)
// pueda solicitar un reseteo de contraseña.
$user = User::where('email', $request->email)
->whereNull('provider') // <-- ¡Aquí está la restricción crucial!
->first();

// Si NO se encuentra un usuario tradicional con ese email,
// devolvemos un mensaje de error genérico para no revelar si el email existe
// o si es una cuenta de Socialite.
if (!$user) {
throw ValidationException::withMessages([
'email' => trans('auth.failed'), // O 'passwords.user' si lo prefieres, pero 'auth.failed' es más genérico y seguro.
]);
}

// Si se encontró el usuario tradicional:
// 1. Generar el token de reseteo de contraseña para ESTE usuario específico.
// createToken() genera y almacena el token en la tabla password_reset_tokens.
$token = Password::createToken($user);

// 2. Enviar el email de reseteo de contraseña a ESTE usuario específico.
// Utilizamos la notificación de reseteo de contraseña de Laravel.
// Esta notificación ya sabe cómo construir el enlace de reseteo.
Notification::send($user, new ResetPasswordNotification($token));

// 3. Devolver un mensaje de éxito.
return back()->with('status', trans(Password::RESET_LINK_SENT));
}
}


Explicación de los Cambios y por qué Garantiza tu Requerimiento:

  1. Búsqueda Explícita y Restringida (User::whereNull('provider')):

    • La línea $user = User::where('email', $request->email)->whereNull('provider')->first(); es el corazón de la solución.

    • Ahora, no solo buscamos por email, sino que forzamos la búsqueda a encontrar solo usuarios donde la columna provider sea NULL. Esto significa que estamos buscando exclusivamente cuentas de login tradicional.

    • Si el email usuario@ejemplo.com existe para un usuario tradicional Y para un usuario de Google, esta consulta solo devolverá el usuario tradicional. Si solo existe para el usuario de Google, esta consulta devolverá null.

  2. Generación de Token para el Usuario Específico (Password::broker()->createToken($user)):

    • Una vez que hemos identificado con certeza al $user que es de login tradicional, llamamos a Password::broker()->createToken($user).

    • Este método genera un token de reseteo de contraseña y lo asocia específicamente con el ID de ese $user en la tabla password_reset_tokens. No hay ambigüedad.

  3. Envío de Notificación al Usuario Específico (Notification::send($user, ...)):

    • Finalmente, usamos Notification::send($user, new ResetPasswordNotification($token)) para enviar el email.

    • Al pasar el objeto $user directamente a Notification::send(), garantizamos que el email se envíe únicamente a la dirección de correo electrónico asociada con ese objeto $user (que sabemos que es el usuario tradicional).

¿Qué pasa si el email usuario@ejemplo.com existe para un usuario de Socialite, pero no para un usuario tradicional?

  • La consulta $user = User::where('email', $request->email)->whereNull('provider')->first(); devolverá null.

  • La condición if (!$user) se activará.

  • Se lanzará la ValidationException con el mensaje trans('auth.failed').

  • El usuario recibirá el mensaje genérico de error, y no se generará ni enviará ningún enlace de reseteo. Esto cumple tu requerimiento de no enviar el enlace a usuarios de Socialite.

Con esta modificación, tienes un control preciso sobre quién puede solicitar y recibir un enlace de reseteo de contraseña, garantizando que solo las cuentas tradicionales sean elegibles.


¿En el método store (done see resetea la contraseña), con el token basta para que aplique la nueva contraseña al usuario correcto?


No, el token por sí solo no es suficiente para que el método store (en NewPasswordController) aplique la nueva contraseña al usuario correcto. Laravel, por seguridad, requiere tanto el token como el email para verificar la solicitud de reseteo de contraseña.

Aquí te explico por qué y cómo funciona:

La Seguridad del Proceso de Reseteo de Contraseña de Laravel

Laravel implementa un proceso de reseteo de contraseña robusto que se basa en la combinación de un token único y el email del usuario para prevenir ataques y asegurar que la contraseña se cambie para la cuenta correcta.

  1. Generación del Token (en PasswordResetLinkController):

    • Cuando solicitas un reseteo, Laravel genera un token criptográficamente seguro y de un solo uso.

    • Este token se almacena en la tabla password_reset_tokens de tu base de datos, asociado al email del usuario y a una fecha de expiración.

    • El token se envía al email del usuario, incrustado en la URL del enlace de reseteo (ej. /reset-password/{token}?email=usuario@ejemplo.com).

  2. Verificación del Token y Email (en NewPasswordController@store):

    • Cuando el usuario hace clic en el enlace y envía el formulario con la nueva contraseña, el método store de NewPasswordController recibe tres datos clave:

      • token (de la URL)

      • email (del formulario, que a menudo se pre-rellena desde la URL)

      • password y password_confirmation (la nueva contraseña)

    • Internamente, Laravel utiliza la fachada Password::broker()->reset() (o un método similar si lo has personalizado) para realizar la verificación. Este método hace lo siguiente:

      • Busca el token en la tabla password_reset_tokens que coincida con el token proporcionado Y el email proporcionado.

      • Verifica la validez del token: Comprueba que el token no haya expirado y que no haya sido utilizado previamente.

      • Localiza al Usuario: Si el token es válido y coincide con el email, Laravel identifica al usuario al que pertenece ese token.

      • Actualiza la Contraseña: Si todas las verificaciones son exitosas, la contraseña del usuario se actualiza en la base de datos.

      • Elimina el Token: El token se elimina de la tabla password_reset_tokens para asegurar que no pueda ser reutilizado.

¿Por qué se necesita el email además del token?

  1. Capa Adicional de Seguridad: Si un atacante lograra obtener un token (por ejemplo, a través de un ataque de phishing muy sofisticado o un error de registro), el requerimiento del email añade una capa extra de protección. El atacante necesitaría tanto el token como el email para completar el reseteo.

  2. Manejo de Emails Duplicados (Tu Caso): En tu escenario específico donde los emails pueden estar duplicados (un usuario tradicional y uno social con el mismo email), el email en la URL del token es crucial.

    • Aunque en el PasswordResetLinkController nos aseguramos de que el token se genere solo para el usuario whereNull('provider'), el proceso de Password::broker()->reset() aún necesita el email para buscar en la tabla password_reset_tokens y luego identificar al usuario.

    • La combinación token + email asegura que, incluso si hay múltiples entradas con el mismo email en la tabla users, el reseteo se aplique al usuario correcto, ya que el token está vinculado a la entrada específica en password_reset_tokens que, a su vez, está vinculada al email del usuario tradicional.

Conclusión

Sí, en el método store del controlador de reseteo de contraseña, el token y el email son ambos necesarios para que Laravel pueda identificar de forma segura al usuario correcto y aplicar la nueva contraseña. El token es el identificador único de la solicitud de reseteo, y el email actúa como una verificación adicional y un medio para localizar la entrada correcta en la tabla de tokens de reseteo.


No hay comentarios: