domingo, 13 de julio de 2025

Login con Socialite y tradicional (parte 2)

 En la parte 1 vimos cómo crear un login y registro de usuario que permitiera que los usuarios de Socialite pudieran reutilizar un email que ya estaba siendo utilizado por un usuario de login tradicional.

Pero nos quedó pendiente ver la situación inversa: cómo hacer que los usuarios de login tradicional pudieran reutilizar un email que ya estaba siendo utilizado por Socialite. Eso vamos a ver en este artículo.

Comenzamos con el siguiente planteo:

La fachada Illuminate\Support\Facades\Auth tiene un método attemp. Según mis reglas de negocio, como ya hablamos, necesito que valide el email solamente si provider es nulo. ¿Puedo entender mejor cómo funciona el método attemp, y la viabilidad por modificar sus parámetros o reemplazarlo por otro método acorde a mi requerimiento?

¡Claro que sí! Entiendo perfectamente tu necesidad de controlar la validación del email de manera condicional, basándote en si el usuario es de Socialite o tradicional.

Vamos a desglosar el método Auth::attempt() y luego veremos cómo adaptarlo a tu regla de negocio sin modificar el método en sí.


¿Cómo funciona el método Auth::attempt()?

El método Auth::attempt() es el corazón del proceso de autenticación de credenciales en Laravel para la autenticación basada en sesión.

Su propósito principal es: intentar autenticar a un usuario basándose en un conjunto de credenciales (típicamente email/nombre de usuario y contraseña) y, si tiene éxito, iniciar una sesión para ese usuario.

Sintaxis básica:

PHP
Auth::attempt(array $credentials, bool $remember = false, bool $login = true)
  • $credentials (array, requerido): Este es un array asociativo de credenciales que Laravel usará para encontrar al usuario en tu base de datos. Lo más común es ['email' => '...', 'password' => '...'] o ['username' => '...', 'password' => '...']. Laravel buscará un usuario que coincida con estas credenciales (excepto la contraseña, que se verifica de forma especial).

  • $remember (boolean, opcional, por defecto false): Si es true, Laravel intentará "recordar" al usuario en futuras visitas, generalmente a través de una cookie de "recuérdame".

  • $login (boolean, opcional, por defecto true): Si es true y la autenticación es exitosa, Laravel iniciará sesión para el usuario. Si lo pones en false, solo verificará las credenciales sin iniciar sesión (útil para ciertas validaciones, aunque menos común).

Lo que hace internamente Auth::attempt():

  1. Busca al Usuario: Utiliza el "guard" de autenticación configurado (por defecto, web) y el "proveedor de usuario" (por defecto, Eloquent User Provider) para buscar un usuario en la base de datos que coincida con las credenciales excepto la contraseña. Por ejemplo, si pasas ['email' => 'test@example.com', 'password' => 'secret'], buscará un usuario con email = 'test@example.com'.

  2. Verifica la Contraseña: Una vez que encuentra un usuario por las credenciales no relacionadas con la contraseña, toma la contraseña proporcionada en $credentials y la compara con la contraseña hasheada almacenada en la base de datos para ese usuario. Utiliza Hash::check() para esta comparación.

  3. Inicia Sesión (si es exitoso): Si el usuario es encontrado y la contraseña coincide, Laravel inicia una sesión para ese usuario, guardando su ID en la sesión y, opcionalmente, una cookie de "recuérdame".

  4. Devuelve un Booleano: Retorna true si la autenticación fue exitosa, false en caso contrario.

Importante: Auth::attempt() no lanza excepciones de validación por sí mismo si las credenciales son incorrectas. Simplemente devuelve false. La excepción de validación que viste en el ejemplo anterior (ValidationException::withMessages([...])) la lanzamos nosotros después de que Auth::attempt() devuelve false, o antes si detectamos una condición específica de negocio (como un email de Socialite).


Viabilidad de modificar sus parámetros o reemplazarlo

  • Modificar sus parámetros:

    • No es viable directamente para tu requerimiento. El método Auth::attempt() es genérico y su propósito es buscar un usuario por las credenciales dadas y verificar la contraseña. No tiene un parámetro incorporado como ignore_social_provider o similar. Modificar el código fuente de Laravel para añadir tal parámetro no es una buena práctica, ya que dificultaría las actualizaciones.

    • Auth::attempt() espera un email (o username) y una contraseña. Si el campo password de un usuario es null en la base de datos (como en tus usuarios de Socialite), Auth::attempt() fallará la verificación de la contraseña, lo cual es el comportamiento deseado para esos usuarios.

  • Reemplazarlo por otro método acorde a tu requerimiento:

    • No necesitas reemplazar Auth::attempt() por completo. El método Auth::attempt() sigue siendo la forma correcta de autenticar usuarios tradicionales con email y contraseña.

    • La clave es realizar una verificación previa antes de llamar a Auth::attempt(). Esto es exactamente lo que te mostré en el ejemplo del controlador de login anterior.


La Solución Acorde a tu Requerimiento (Reforzando el Concepto)

La estrategia correcta es la que ya implementamos:

  1. Validación Previa de Negocio: Antes de intentar la autenticación con Auth::attempt(), realizas una consulta a la base de datos para verificar si el email proporcionado pertenece a un usuario de Socialite.

  2. Lanzar Excepción Temprana: Si el email pertenece a un usuario de Socialite, lanzas tu propia ValidationException con un mensaje claro para el usuario. Esto evita que Auth::attempt() intente (y falle) inútilmente la autenticación de una contraseña inexistente y proporciona un mejor feedback al usuario.

  3. Uso Estándar de Auth::attempt(): Si la verificación previa determina que el email no pertenece a un usuario de Socialite (es decir, es un usuario tradicional o un email no registrado), entonces procedes a llamar a Auth::attempt() de la manera estándar. Si Auth::attempt() devuelve false en este punto, sabes que las credenciales (email o contraseña) son incorrectas para un usuario tradicional.

Fragmento de código clave (del controlador de login):

PHP
// ...
use App\Models\User; // Asegúrate de importar tu modelo User
use Illuminate\Validation\ValidationException; // Importar ValidationException

public function store(LoginRequest $request): RedirectResponse
{
    // ... validación del request (email, password) ...

    // PASO CLAVE: Verificar si el email existe y si es un usuario de Socialite
    $user = User::where('email', $request->email)->first();

    if ($user && $user->provider !== null) {
        // Si el usuario existe y tiene un proveedor social,
        // lanzamos una excepción personalizada.
        throw ValidationException::withMessages([
            'email' => __('auth.socialite_account_exists', ['provider' => ucfirst($user->provider)]),
        ]);
    }

    // Si llegamos aquí, el email no existe, O existe y es un usuario tradicional.
    // Intentamos la autenticación normal con email y contraseña.
    if (! Auth::attempt($request->only('email', 'password'), $request->boolean('remember'))) {
        // Si Auth::attempt() falla, significa que las credenciales son incorrectas
        // para un usuario tradicional (o el email no existe en absoluto).
        throw ValidationException::withMessages([
            'email' => trans('auth.failed'),
        ]);
    }

    // ... si la autenticación fue exitosa, regenerar sesión y redirigir ...
}

Este enfoque te permite mantener la robustez y la simplicidad de Auth::attempt() para su propósito original, mientras implementas tu lógica de negocio específica de manera limpia y clara antes de intentar la autenticación.



Mi requerimiento es que pueda haber emails repetidos mientras que uno solo sea con provider nulo y cada email repetido tenga un provider distinto de nulo y distinto entre sí. ¿Cómo se da cuenta el método attemp que quiero restringir su ámbito de búsqueda a aquellos usuarios/emails con provider nulo?


¡Claro que sí! Tu requerimiento es muy específico y común en aplicaciones modernas. Tienes razón en que el método Auth::attempt() por sí mismo no tiene forma de "saber" que debe restringir su búsqueda a usuarios con provider nulo.

Entendiendo Auth::attempt() y tu Requerimiento

El método Auth::attempt() está diseñado para ser una forma conveniente de autenticar usuarios basándose en un conjunto de credenciales (típicamente email o username y password). Internamente, hace dos cosas principales:

  1. Busca un usuario: Busca un usuario en la base de datos que coincida con las credenciales proporcionadas (excepto la contraseña). Por ejemplo, si le pasas ['email' => 'test@example.com', 'password' => 'secret'], buscará User::where('email', 'test@example.com')->first(). Aquí está la clave: no filtra por provider por defecto.

  2. Verifica la contraseña: Si encuentra un usuario, compara la contraseña proporcionada con la contraseña hasheada almacenada para ese usuario.

Dado tu requerimiento de que:

  • Pueda haber emails repetidos.

  • Solo UN email repetido puede ser para un usuario tradicional (provider nulo).

  • Otros emails repetidos deben ser para usuarios sociales (provider no nulo y distinto entre sí).

Esto significa que Auth::attempt() no es suficiente por sí solo, porque si hay un email usuario@ejemplo.com que es tanto un usuario tradicional como un usuario de Google, Auth::attempt() encontrará la primera coincidencia por email y luego intentará verificar la contraseña, lo cual podría llevar a un comportamiento inesperado si encuentra primero la cuenta de Google (que no tiene contraseña).

La Solución: Control Manual de la Autenticación Tradicional

Para lograr tu requerimiento, la estrategia es no depender de Auth::attempt() para la búsqueda inicial del usuario tradicional, sino realizar una búsqueda y verificación más explícita y manual antes de loguear al usuario.

Así es como modificaríamos el controlador de login y el de registro:


I. Modificación de la Base de Datos (Migración)

Primero, asegurémonos de que la base de datos permita esto.

1. Crea una nueva migración (si no lo has hecho ya):

Bash
php artisan make:migration add_socialite_fields_and_adjust_email_to_users_table --table=users

2. Edita el archivo de migración (database/migrations/..._add_socialite_fields_and_adjust_email_to_users_table.php):

PHP
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::table('users', function (Blueprint $table) {
            // 1. Hacer el email nullable y eliminar la restricción UNIQUE existente.
            //    Laravel por defecto crea 'users_email_unique'. Si ya existe, debes eliminarlo.
            //    Si estás empezando un proyecto nuevo, podrías editar la migración inicial de users.
            if (Schema::hasColumn('users', 'email')) {
                // Si el índice único ya existe, intenta eliminarlo primero.
                // Esto es crucial si ya tenías la restricción UNIQUE en 'email'.
                $sm = Schema::getConnection()->getDoctrineSchemaManager();
                $indexes = $sm->listTableIndexes('users');
                if (isset($indexes['users_email_unique'])) {
                    $table->dropUnique('users_email_unique');
                }
                $table->string('email')->nullable()->change();
            }

            // 2. Hacer la contraseña nullable (para usuarios de Socialite)
            if (Schema::hasColumn('users', 'password')) {
                $table->string('password')->nullable()->change();
            }


            // 3. Añadir campos para Socialite
            $table->string('provider')->nullable();
            $table->string('provider_id')->nullable();

            // 4. Añadir un índice único compuesto para provider y provider_id
            //    Esto asegura que no haya duplicados para el mismo proveedor (ej. no dos usuarios de Google con el mismo Google ID).
            $table->unique(['provider', 'provider_id']);

            // 5. Opcional: Si tu base de datos soporta índices únicos condicionales (ej. PostgreSQL),
            //    podrías añadir una restricción única en 'email' donde 'provider' es NULL.
            //    Para MySQL o SQLite, esto generalmente se maneja a nivel de aplicación,
            //    como se hará en el controlador de registro.
            // Ejemplo para PostgreSQL:
            // $table->unique('email')->whereNull('provider');
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->dropUnique(['provider', 'provider_id']);
            $table->dropColumn(['provider', 'provider_id']);

            // Revertir password a no-nullable (si era así originalmente)
            // Cuidado: si ya tienes usuarios con password null, esto fallará.
            // $table->string('password')->nullable(false)->change();

            // Revertir email a no-nullable y añadir de nuevo la restricción UNIQUE
            // Cuidado: si ya tienes emails duplicados (por Socialite), esto fallará.
            // $table->string('email')->nullable(false)->change();
            // $table->unique('email');
        });
    }
};

3. Ejecuta la migración:

Bash
php artisan migrate

II. Lógica de Autenticación (Controladores)

Aquí es donde implementamos la lógica para tus reglas de negocio.

1. RegisteredUserController (Registro de Usuarios Tradicionales)

Aquí validamos la unicidad del email para los usuarios tradicionales.

PHP
<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Illuminate\Validation\ValidationException;

class RegisteredUserController extends Controller
{
    // ... (otros métodos)

    public function store(Request $request): RedirectResponse
    {
        $request->validate([
            'name' => ['required', 'string', 'max:255'],
            'email' => ['required', 'string', 'email', 'max:255'],
            'password' => ['required', 'confirmed', Rules\Password::defaults()],
        ]);

        // REGLA DE NEGOCIO: El email debe ser único para usuarios TRADICIONALES (provider es nulo)
        $existingTraditionalUser = User::where('email', $request->email)
                                       ->whereNull('provider') // Solo buscamos usuarios tradicionales
                                       ->first();

        if ($existingTraditionalUser) {
            throw ValidationException::withMessages([
                'email' => ['El email ya está registrado para una cuenta tradicional.'],
            ]);
        }

        $user = User::create([
            'name' => $request->name,
            'email' => $request->email,
            'password' => Hash::make($request->password),
            'provider' => null,      // Este es un usuario tradicional
            'provider_id' => null,   // Este es un usuario tradicional
            'email_verified_at' => null, // Opcional: se verificará después
        ]);

        event(new Registered($user));

        Auth::login($user);

        return redirect(route('dashboard', absolute: false));
    }
}

2. AuthenticatedSessionController (Login Tradicional)

Aquí es donde reemplazamos Auth::attempt() con una lógica más granular para asegurar que solo se intente loguear a un usuario tradicional.

PHP
<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use App\Models\User; // Importa tu modelo User
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash; // Necesario para Hash::check
use Illuminate\Validation\ValidationException;

class AuthenticatedSessionController extends Controller
{
    // ... (método create)

    public function store(LoginRequest $request): RedirectResponse
    {
        $request->validate([
            'email' => ['required', 'string', 'email'],
            'password' => ['required', 'string'],
        ]);

        // PASO CLAVE: Buscar el usuario tradicional por email
        $user = User::where('email', $request->email)
                    ->whereNull('provider') // ¡Restringimos la búsqueda a usuarios tradicionales!
                    ->first();

        // 1. Si no se encuentra un usuario tradicional con ese email
        //    O si se encuentra pero la contraseña no coincide
        if (!$user || !Hash::check($request->password, $user->password)) {
            // Lanzamos la excepción de validación con el mensaje genérico de "fallo"
            // Esto cubre: email no encontrado, o email encontrado pero contraseña incorrecta.
            throw ValidationException::withMessages([
                'email' => trans('auth.failed'),
            ]);
        }

        // 2. Si el usuario tradicional fue encontrado Y la contraseña coincide
        //    Procedemos a iniciar sesión manualmente.
        Auth::login($user, $request->boolean('remember'));

        $request->session()->regenerate();

        return redirect()->intended(route('dashboard', absolute: false));
    }

    // ... (método destroy)
}

3. SocialiteController (Login con Socialite)

Este controlador permanece similar, ya que su lógica es encontrar/crear usuarios basados en el proveedor social, no en email/contraseña.

PHP
<?php

namespace App\Http\Controllers;

use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Laravel\Socialite\Facades\Socialite;
use Illuminate\Support\Facades\Hash; // Aunque password es null, lo mantenemos por si acaso

class SocialiteController extends Controller
{
    // ... (redirectToProvider)

    public function handleProviderCallback(string $provider)
    {
        try {
            $socialUser = Socialite::driver($provider)->user();
        } catch (\Exception $e) {
            return redirect('/login')->withErrors(['social' => 'No se pudo autenticar con ' . ucfirst($provider) . '. Por favor, inténtalo de nuevo.']);
        }

        // Buscar usuario por provider_id y provider
        $user = User::where('provider', $provider)
                    ->where('provider_id', $socialUser->getId())
                    ->first();

        if ($user) {
            // Si el usuario ya existe (por Socialite), iniciar sesión
            Auth::login($user);
            return redirect('/dashboard');
        } else {
            // Si el usuario no existe, crear uno nuevo
            // NOTA: El email puede ser null. La contraseña es null.
            // Antes de crear, podrías querer verificar si ya existe un usuario TRADICIONAL con ese email
            // y preguntar al usuario si quiere vincular la cuenta social existente.
            // Para este requerimiento, simplemente creamos una nueva cuenta social.

            $newUser = User::create([
                'name' => $socialUser->getName() ?? $socialUser->getNickname() ?? 'Usuario Social',
                'email' => $socialUser->getEmail(), // Puede ser null si el proveedor no lo da
                'password' => null, // Usuarios de Socialite no tienen contraseña
                'provider' => $provider,
                'provider_id' => $socialUser->getId(),
                'email_verified_at' => $socialUser->getEmail() ? now() : null, // Marcar verificado si hay email
            ]);

            Auth::login($newUser);
            return redirect('/dashboard');
        }
    }
}

Resumen de la Solución:

  1. Base de Datos Flexible: El campo email ya no es UNIQUE globalmente, y password es nullable. Se usa un índice compuesto unique(['provider', 'provider_id']) para la unicidad de Socialite.

  2. Control Explicito en Login Tradicional: En el AuthenticatedSessionController@store, ya no usamos Auth::attempt(). En su lugar:

    • Buscamos explícitamente un usuario donde email coincida Y provider sea NULL.

    • Si encontramos ese usuario, verificamos manualmente la contraseña con Hash::check().

    • Si todo es correcto, usamos Auth::login($user) para iniciar la sesión.

    • Si no se encuentra un usuario tradicional o la contraseña es incorrecta, lanzamos la ValidationException con el mensaje de error genérico.

  3. Control Explicito en Registro Tradicional: En el RegisteredUserController@store, antes de crear un nuevo usuario, verificamos que no exista ya un usuario tradicional con el email que se está intentando registrar.

  4. Socialite Sin Cambios Mayores: El SocialiteController sigue su lógica de encontrar/crear usuarios por provider y provider_id, sin preocuparse por la contraseña o la unicidad global del email.

Esta implementación te da el control granular que necesitas para tus reglas de negocio específicas sobre la unicidad del email y los diferentes tipos de login.

No hay comentarios: