miércoles, 9 de julio de 2025

Login con socialite y tradicional

 Quiero que en mi aplicación web se puedan autenticar a través de Socialite, y además con email y contraseña con un formulario básico. Quiero que los usuarios que se autentican mediante Socialite no estén obligados a informar un email. Quiero que incluso el email ya no sea único, en cambio, quiero que el email sea único solamente para los logins por formulario tradicional.


¡Claro que sí! Tu requerimiento es muy interesante y aborda un escenario común en aplicaciones web modernas. No hay ningún error de concepto, pero sí implica un manejo cuidadoso de la base de datos y la lógica de autenticación.

El desafío principal es cómo manejar la unicidad del email cuando algunos usuarios (Socialite) no lo proporcionan o no lo necesitan como identificador único, mientras que otros (formulario tradicional) sí lo requieren único.

La solución más práctica y compatible con la mayoría de las bases de datos es eliminar la restricción UNIQUE del campo email en la base de datos y manejar la unicidad del email para los usuarios tradicionales a nivel de la lógica de la aplicación. Además, haremos que el campo password sea nullable para los usuarios de Socialite.

Aquí te muestro cómo implementar esto paso a paso:


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

Primero, necesitamos ajustar la tabla users para permitir emails nulos y almacenar la información del proveedor social.

1. Crea una nueva migración:

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

2. Edita el archivo de migración (database/migrations/..._add_socialite_fields_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
            //    Asegúrate de que no haya un índice único nombrado 'users_email_unique'
            //    Si lo hay, necesitarás eliminarlo primero si no lo has hecho ya.
            //    Laravel por defecto crea 'users_email_unique'.
            $table->string('email')->nullable()->change(); // Cambia a nullable
            // Si el índice único ya existe y no se eliminó automáticamente al hacer nullable,
            // descomenta la siguiente línea para eliminarlo explícitamente:
            // $table->dropUnique(['email']);

            // 2. Hacer la contraseña nullable (para usuarios de Socialite)
            $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.
            $table->unique(['provider', 'provider_id']);
        });
    }

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

            // Revertir password a no-nullable (si era así originalmente)
            $table->string('password')->nullable(false)->change();

            // Revertir email a no-nullable y añadir de nuevo la restricción UNIQUE
            // Esto solo si quieres revertir completamente al estado original.
            // Si la aplicación ya tiene emails nulos, esto fallará.
            $table->string('email')->nullable(false)->change();
            $table->unique('email'); // Vuelve a añadir la restricción UNIQUE
        });
    }
};

3. Ejecuta la migración:

Bash
php artisan migrate

II. Configuración de Laravel Socialite

1. Instala Socialite:

Bash
composer require laravel/socialite

2. Publica la configuración de Socialite:

Bash
php artisan vendor:publish --provider="Laravel\Socialite\SocialiteServiceProvider"

Esto creará o actualizará config/services.php.

3. Configura tus proveedores en config/services.php y .env:

PHP
// config/services.php
return [
    // ...
    'github' => [
        'client_id' => env('GITHUB_CLIENT_ID'),
        'client_secret' => env('GITHUB_CLIENT_SECRET'),
        'redirect' => env('GITHUB_REDIRECT_URI'),
    ],
    'google' => [
        'client_id' => env('GOOGLE_CLIENT_ID'),
        'client_secret' => env('GOOGLE_CLIENT_SECRET'),
        'redirect' => env('GOOGLE_REDIRECT_URI'),
    ],
    // ...
];

Asegúrate de añadir las credenciales en tu archivo .env:

Code snippet
GITHUB_CLIENT_ID=your-github-app-id
GITHUB_CLIENT_SECRET=your-github-app-secret
GITHUB_REDIRECT_URI="http://localhost:8000/auth/github/callback"

GOOGLE_CLIENT_ID=your-google-app-id
GOOGLE_CLIENT_SECRET=your-google-app-secret
GOOGLE_REDIRECT_URI="http://localhost:8000/auth/google/callback"

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

Necesitarás un controlador para Socialite y ajustar tu controlador de registro tradicional.

1. Crea el controlador de Socialite:

Bash
php artisan make:controller SocialiteController

2. Edita app/Http/Controllers/SocialiteController.php:

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; // Necesario si decides generar una contraseña aleatoria

class SocialiteController extends Controller
{
    /**
     * Redirige al usuario al proveedor de OAuth.
     *
     * @param string $provider
     * @return \Symfony\Component\HttpFoundation\RedirectResponse
     */
    public function redirectToProvider(string $provider)
    {
        return Socialite::driver($provider)->redirect();
    }

    /**
     * Maneja la devolución de llamada del proveedor de OAuth.
     *
     * @param string $provider
     * @return \Illuminate\Http\RedirectResponse
     */
    public function handleProviderCallback(string $provider)
    {
        try {
            // Obtener los datos del usuario del proveedor.
            // Esto puede fallar si el usuario deniega el acceso o hay un problema de red.
            $socialUser = Socialite::driver($provider)->user();
        } catch (\Exception $e) {
            // Manejar el error, por ejemplo, redirigir a login con un mensaje de error.
            return redirect('/login')->withErrors(['social' => 'No se pudo autenticar con ' . ucfirst($provider) . '. Por favor, inténtalo de nuevo.']);
        }

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

        if ($user) {
            // Si el usuario ya existe, iniciar sesión
            Auth::login($user);
            return redirect('/dashboard');
        } else {
            // Si el usuario no existe, crear uno nuevo
            // NOTA: El email puede ser null si el proveedor no lo proporciona o si el usuario no lo compartió.
            // La contraseña será null para usuarios de Socialite.
            $newUser = User::create([
                'name' => $socialUser->getName() ?? $socialUser->getNickname() ?? 'Usuario Social',
                'email' => $socialUser->getEmail(), // Puede ser null
                'password' => null, // Los usuarios de Socialite no tienen contraseña
                'provider' => $provider,
                'provider_id' => $socialUser->getId(),
                // 'email_verified_at' => now(), // Puedes verificar el email si el proveedor lo confirma
            ]);

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

3. Ajusta tu RegisterController (o el método register en tu Auth/RegisteredUserController si usas Breeze/Jetstream):

Aquí es donde aplicamos la lógica de 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; // Importar ValidationException

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

    /**
     * Handle an incoming registration request.
     *
     * @throws \Illuminate\Validation\ValidationException
     */
    public function store(Request $request): RedirectResponse
    {
        $request->validate([
            'name' => ['required', 'string', 'max:255'],
            'email' => ['required', 'string', 'email', 'max:255'], // Email es requerido para el formulario tradicional
            'password' => ['required', 'confirmed', Rules\Password::defaults()],
        ]);

        // Lógica para asegurar que el email sea único SÓLO para logins tradicionales
        // Esto significa que no puede haber otro usuario (tradicional o social)
        // que ya tenga este email.
        if (User::where('email', $request->email)->exists()) {
            // Si el email ya existe, lanzamos una excepción de validación
            throw ValidationException::withMessages([
                'email' => ['El email ya está registrado.'],
            ]);
        }

        $user = User::create([
            'name' => $request->name,
            'email' => $request->email,
            'password' => Hash::make($request->password),
            'provider' => null, // Este usuario no tiene proveedor social
            'provider_id' => null, // Este usuario no tiene ID de proveedor social
        ]);

        event(new Registered($user));

        Auth::login($user);

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

IV. Rutas de Laravel

Añade las rutas para Socialite en routes/web.php:

PHP
// routes/web.php

use App\Http\Controllers\SocialiteController;
use App\Http\Controllers\Auth\RegisteredUserController; // Si usas Laravel Breeze/Jetstream

// ... otras rutas

// Rutas de Socialite
Route::get('/auth/{provider}/redirect', [SocialiteController::class, 'redirectToProvider'])->name('socialite.redirect');
Route::get('/auth/{provider}/callback', [SocialiteController::class, 'handleProviderCallback'])->name('socialite.callback');

// Ajusta la ruta de registro si usaste el ejemplo de RegisteredUserController
Route::post('/register', [RegisteredUserController::class, 'store'])->middleware('guest');

// ... tus rutas de dashboard, login, etc.

V. Frontend (Blade o Inertia/React)

Finalmente, necesitas los formularios y botones en tu frontend.

1. Plantilla Blade (ej. resources/views/auth/login.blade.php o register.blade.php):

HTML
<!-- resources/views/auth/login.blade.php (o similar) -->

<form method="POST" action="{{ route('login') }}">
    @csrf
    <!-- Campos de email y contraseña -->
    <div>
        <label for="email">Email</label>
        <input id="email" type="email" name="email" required autofocus />
        @error('email') <span>{{ $message }}</span> @enderror
    </div>
    <div>
        <label for="password">Contraseña</label>
        <input id="password" type="password" name="password" required />
        @error('password') <span>{{ $message }}</span> @enderror
    </div>
    <button type="submit">Iniciar Sesión</button>
</form>

<hr>

<!-- Botones de Socialite -->
<div>
    <p>O inicia sesión con:</p>
    <a href="{{ route('socialite.redirect', ['provider' => 'google']) }}">
        Iniciar Sesión con Google
    </a>
    <br>
    <a href="{{ route('socialite.redirect', ['provider' => 'github']) }}">
        Iniciar Sesión con GitHub
    </a>
</div>

@if ($errors->has('social'))
    <div style="color: red;">
        {{ $errors->first('social') }}
    </div>
@endif

2. Si usas Inertia/React para el formulario de login/registro:

Los botones de Socialite seguirían siendo enlaces <a> normales que apuntan a las rutas de Socialite. Para el formulario de email/contraseña, usarías un componente React con un form que hace un POST a route('login').

JavaScript
// resources/js/Pages/Auth/Login.jsx (ejemplo con Inertia/React)

import React, { useEffect } from 'react';
import { Head, useForm, Link } from '@inertiajs/react';
import GuestLayout from '@/Layouts/GuestLayout'; // Asumiendo un layout para invitados

export default function Login({ status, canResetPassword }) {
    const { data, setData, post, processing, errors, reset } = useForm({
        email: '',
        password: '',
        remember: false,
    });

    useEffect(() => {
        return () => {
            reset('password');
        };
    }, []);

    const submit = (e) => {
        e.preventDefault();
        post(route('login'));
    };

    return (
        <GuestLayout>
            <Head title="Log in" />

            {status && <div className="mb-4 font-medium text-sm text-green-600">{status}</div>}

            <form onSubmit={submit}>
                <div>
                    <label htmlFor="email">Email</label>
                    <input
                        id="email"
                        type="email"
                        name="email"
                        value={data.email}
                        className="mt-1 block w-full"
                        autoComplete="username"
                        onChange={(e) => setData('email', e.target.value)}
                    />
                    {errors.email && <div className="text-red-500 text-sm mt-1">{errors.email}</div>}
                </div>

                <div className="mt-4">
                    <label htmlFor="password">Contraseña</label>
                    <input
                        id="password"
                        type="password"
                        name="password"
                        value={data.password}
                        className="mt-1 block w-full"
                        autoComplete="current-password"
                        onChange={(e) => setData('password', e.target.value)}
                    />
                    {errors.password && <div className="text-red-500 text-sm mt-1">{errors.password}</div>}
                </div>

                {/* ... (checkbox de recordar, enlace de contraseña olvidada) ... */}

                <div className="flex items-center justify-end mt-4">
                    <button className="ms-4" disabled={processing}>
                        Iniciar Sesión
                    </button>
                </div>
            </form>

            <hr className="my-6" />

            {/* Botones de Socialite */}
            <div className="text-center">
                <p className="mb-4">O inicia sesión con:</p>
                <a href={route('socialite.redirect', { provider: 'google' })} className="inline-block px-4 py-2 border rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50">
                    Google
                </a>
                <a href={route('socialite.redirect', { provider: 'github' })} className="inline-block px-4 py-2 border rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 ml-4">
                    GitHub
                </a>
            </div>
            {/* Si el controlador de Socialite envía errores, puedes mostrarlos aquí */}
            {errors.social && <div className="text-red-500 text-sm mt-4 text-center">{errors.social}</div>}
        </GuestLayout>
    );
}

Con esta configuración, habrás logrado:

  • Autenticación Dual: Usuarios pueden elegir entre Socialite o email/contraseña.

  • Email Nullable para Socialite: Los usuarios de Socialite no están obligados a tener un email, y su campo password será null.

  • Email Único para Tradicional: La unicidad del email se aplica para los usuarios que se registran con el formulario tradicional. Si un email ya existe (ya sea de un usuario social o tradicional), no se permitirá un nuevo registro tradicional con ese email.

  • Manejo de Login: Los usuarios de Socialite siempre iniciarán sesión a través de su proveedor social. Los usuarios tradicionales usarán su email y contraseña.

Esta es una solución robusta y flexible para tu requerimiento.



Reaccionando al primer cambio

En respuesta a la adaptación de Socialite ¿Hay que cambiar algo en las funcionalidades de reset de contraseña o verificación de email?


¡Sí, absolutamente! La adaptación para Socialite, especialmente al hacer el email y la contraseña nullable y el email no único para todos, implica cambios importantes en las funcionalidades de reseteo de contraseña y verificación de email.

Aquí te explico los puntos clave:


1. Reseteo de Contraseña

Implicación Principal: Los usuarios que se autentican a través de Socialite no tienen una contraseña tradicional en tu base de datos (su campo password es null). Por lo tanto, no pueden ni deben usar la funcionalidad estándar de reseteo de contraseña.

Qué hay que cambiar/considerar:

  • Diferenciación de Usuarios: Tu formulario y lógica de reseteo de contraseña deben poder distinguir entre usuarios tradicionales y usuarios de Socialite.

    • En el Frontend: Cuando un usuario intenta resetear su contraseña (ej. ingresando su email), tu aplicación debe verificar si ese email pertenece a un usuario tradicional (que tiene password no nulo y provider nulo) o a un usuario de Socialite.

    • En el Backend (Controlador de Reseteo): Antes de enviar el correo de reseteo, debes añadir una validación para asegurarte de que el email proporcionado pertenezca a un usuario que realmente tiene una contraseña en tu sistema.

      PHP
      // En tu controlador de reseteo de contraseña (ej. app/Http/Controllers/Auth/PasswordController.php o similar)
      // Antes de enviar el enlace de reseteo...
      $user = User::where('email', $request->email)->first();
      
      if (!$user || $user->provider !== null) { // Si no existe o es un usuario social
          return back()->withErrors(['email' => 'No podemos encontrar un usuario con esa dirección de correo electrónico o el usuario no tiene una contraseña asociada.']);
      }
      
      // Si el usuario existe y no es social, procede con el envío del correo de reseteo.
      
  • Mensajes al Usuario: Es crucial proporcionar mensajes claros al usuario. Si un usuario de Socialite intenta resetear su contraseña, no le digas "email no encontrado", sino algo como "Este email está asociado a una cuenta de inicio de sesión social. Por favor, inicia sesión con [Google/GitHub, etc.]".

  • Alternativas para Usuarios de Socialite: Si un usuario de Socialite quiere añadir una contraseña para poder iniciar sesión también de forma tradicional, necesitarías una funcionalidad separada en el perfil del usuario (una vez autenticado) que le permita "establecer una contraseña". Esto implicaría actualizar su campo password en la base de datos.


2. Verificación de Email

Implicación Principal: La verificación de email es relevante principalmente para usuarios que se registran con email y contraseña. Para los usuarios de Socialite, la situación es más matizada.

Qué hay que cambiar/considerar:

  • Usuarios de Socialite sin Email: Si un proveedor social no proporciona un email, o el usuario elige no compartirlo, entonces la verificación de email es irrelevante para ese usuario, ya que no tienen un email que verificar.

  • Usuarios de Socialite con Email: Si el proveedor social proporciona un email:

    • Confianza en el Proveedor: Generalmente, se asume que el email proporcionado por un proveedor de OAuth (como Google o Facebook) ya está "verificado" por ese proveedor. Por lo tanto, podrías considerar que email_verified_at se establezca a now() automáticamente al crear la cuenta de Socialite.

      PHP
      // En tu SocialiteController, al crear un nuevo usuario:
      $newUser = User::create([
          // ...
          'email' => $socialUser->getEmail(),
          'email_verified_at' => $socialUser->getEmail() ? now() : null, // Verifica si hay email antes de marcar como verificado
          // ...
      ]);
      
    • Verificación Adicional (Opcional): En algunos casos, podrías querer que incluso los emails de Socialite sean verificados por tu propia aplicación (por ejemplo, si la confianza en el proveedor es menor o si tienes requisitos de seguridad muy altos). Si este es el caso, el proceso sería el mismo que para los usuarios tradicionales, pero solo se aplicaría a los usuarios de Socialite que tengan un email.

  • Condicionalidad en el Middleware: El middleware verified de Laravel (que protege rutas hasta que el email esté verificado) debe aplicarse solo a los usuarios tradicionales. Si un usuario de Socialite no tiene email o no lo has verificado, este middleware les bloquearía el acceso.

    • Podrías necesitar un middleware personalizado o una lógica en tu AuthServiceProvider para que la verificación de email solo se aplique a usuarios con provider nulo y password no nulo.

    • Alternativamente, puedes hacer que el middleware verified verifique si el usuario tiene un email y si está verificado, y si no tiene email (es social), lo deje pasar.


En resumen, la clave es diferenciar claramente entre los tipos de usuarios (tradicional vs. social) en tu lógica de autenticación y adaptar las funcionalidades de reseteo de contraseña y verificación de email para que solo se apliquen a los usuarios para los que son relevantes.


En respuesta a la adaptación de Socialite ¿Hay que cambiar algo en el controlador de login?


¡Sí, es muy importante considerar y posiblemente ajustar tu controlador de login (o el método store en AuthenticatedSessionController si usas Laravel Breeze/Jetstream) en respuesta a la integración de Socialite y los cambios en la base de datos (email y contraseña nullable).

El cambio principal no es tanto en la funcionalidad de login tradicional, sino en la validación y el manejo de errores para asegurar que un usuario de Socialite no intente iniciar sesión con email y contraseña cuando no tiene una contraseña establecida en tu sistema.


Ajustes en el Controlador de Login

Asumamos que tienes un controlador de login básico, similar al que genera Laravel Breeze (app/Http/Controllers/Auth/AuthenticatedSessionController.php).

El cambio clave estará en el método store (o el método que maneja la lógica de autenticación del formulario):

PHP
<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest; // Si usas un Form Request
use App\Models\User; // Importa tu modelo User
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException; // Importar ValidationException

class AuthenticatedSessionController extends Controller
{
    /**
     * Display the login view.
     */
    public function create(): \Illuminate\View\View
    {
        return view('auth.login');
    }

    /**
     * Handle an incoming authentication request.
     *
     * @throws \Illuminate\Validation\ValidationException
     */
    public function store(LoginRequest $request): RedirectResponse
    {
        // La validación inicial del request (email, password) se maneja por LoginRequest
        // o directamente aquí con $request->validate([...]);

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

        // Si el usuario existe pero es un usuario de Socialite (tiene 'provider' no nulo
        // y/o 'password' nulo), no permitimos el login tradicional.
        if ($user && $user->provider !== null) {
            throw ValidationException::withMessages([
                'email' => __('auth.socialite_account_exists', ['provider' => ucfirst($user->provider)]),
                // O un mensaje más genérico: 'Esta cuenta está registrada a través de un inicio de sesión social. Por favor, usa el método de autenticación social.'
            ]);
        }

        // Si el email no existe, o si existe y es un usuario tradicional,
        // intentamos la autenticación normal.
        // Si el email no existe, Auth::attempt() fallará y lanzará la excepción 'failed'.
        // Si el email existe y es tradicional pero la contraseña es incorrecta, también fallará.
        if (! Auth::attempt($request->only('email', 'password'), $request->boolean('remember'))) {
            throw ValidationException::withMessages([
                'email' => trans('auth.failed'), // Mensaje de error de autenticación fallida
            ]);
        }

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

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

    /**
     * Destroy an authenticated session.
     */
    public function destroy(Request $request): RedirectResponse
    {
        Auth::guard('web')->logout();

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

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

        return redirect('/');
    }
}

Explicación del Cambio Clave:

  1. Búsqueda Previa del Usuario: Antes de llamar a Auth::attempt(), buscamos si ya existe un usuario con el email proporcionado.

  2. Verificación de Usuario Social: Si encontramos un usuario con ese email Y user->provider no es null (lo que indica que es una cuenta de Socialite), entonces lanzamos una excepción de validación personalizada. Esto evita que un usuario de Socialite intente iniciar sesión con un email que no tiene una contraseña asociada en tu sistema.

    • El mensaje de error es crucial para guiar al usuario. Puedes usar __('auth.socialite_account_exists', ['provider' => ucfirst($user->provider)]) si defines ese mensaje en tu archivo de traducciones lang/es/auth.php. Por ejemplo:

      PHP
      // lang/es/auth.php
      return [
          // ...
          'socialite_account_exists' => 'Esta cuenta está asociada a un inicio de sesión con :provider. Por favor, inicia sesión usando ese método.',
      ];
      
  3. Login Tradicional: Si el email no existe o si pertenece a un usuario tradicional (sin provider y con password no nulo), entonces procedemos con el Auth::attempt() normal.

¿Por qué es necesario este cambio?

  • Claridad para el Usuario: Evita que los usuarios de Socialite se frustren intentando iniciar sesión con un email que no tiene una contraseña en tu base de datos. Les proporciona una guía clara.

  • Seguridad y Lógica: Refuerza la lógica de tu aplicación, asegurando que los métodos de autenticación se utilicen correctamente para el tipo de cuenta correspondiente.

  • Manejo de Errores: Permite mostrar un mensaje de error más específico y útil en lugar de un genérico "Credenciales incorrectas" que no explica la situación real.

En resumen, el controlador de login necesita una pequeña pero importante validación adicional para manejar correctamente los casos en los que un email proporcionado para un login tradicional ya está asociado a una cuenta de Socialite.

No hay comentarios: