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:
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
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:
php artisan migrate
II. Configuración de Laravel Socialite
1. Instala Socialite:
composer require laravel/socialite
2. Publica la configuración de Socialite:
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
:
// 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
:
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:
php artisan make:controller SocialiteController
2. Edita app/Http/Controllers/SocialiteController.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
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
:
// 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
):
<!-- 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')
.
// 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 yprovider
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 sí 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 anow()
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 conprovider
nulo ypassword
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
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:
Búsqueda Previa del Usuario: Antes de llamar a
Auth::attempt()
, buscamos si ya existe un usuario con el email proporcionado.Verificación de Usuario Social: Si encontramos un usuario con ese email Y
user->provider
no esnull
(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 traduccioneslang/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.', ];
Login Tradicional: Si el email no existe o si pertenece a un usuario tradicional (sin
provider
y conpassword
no nulo), entonces procedemos con elAuth::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:
Publicar un comentario