Crear Recuperación de Contraseña en Laravel

Guía Completa para implementar una recuperación de contraseña en Laravel 10

Olvidar la contraseña de acceso es un problema común que todos los usuarios han experimentado alguna vez en servicios online. Por ese motivo, es esencial que cualquier aplicación web moderna cuente con un proceso seguro y eficiente para restablecer contraseñas olvidadas.

En este artículo veremos cómo implementar paso a paso la funcionalidad de recuperación de contraseña en Laravel 10, el popular framework PHP para desarrollo de aplicaciones. Aprenderemos a:

  • Generar tokens únicos de restablecimiento.

  • Enviar emails al usuario con enlaces temporales.

  • Crear una vista para establecer la nueva contraseña.

  • Actualizar la contraseña en la base de datos.

  • Consideraciones importantes de seguridad.

  • Manejo de errores y personalización.

Al finalizar, tendremos una sólida característica de "¿Olvido su contraseña?" completa e integrada a nuestro proyecto Laravel. ¡Comencemos!

¿Qué es la recuperación de contraseña y por qué es importante?

La recuperación o restablecimiento de contraseña permite a los usuarios generar una nueva contraseña cuando han olvidado la anterior.

Esto es esencial por seguridad y experiencia de usuario. Evita que los usuarios se bloqueen de sus cuentas y permite restaurar su acceso.


Preparando el proyecto Laravel

Lo primero será configurar un proyecto nuevo con las rutas, controladores y modelos necesarios.

Crear un nuevo proyecto Laravel 10

Comenzaremos creando una aplicación nueva con Laravel 10:

composer create-project laravel/laravel mi-proyecto-laravel

Esto generará la estructura de carpetas y archivos iniciales del proyecto.

Configurar la base de datos

Con el proyecto creado accedemos a nuestro .env con los datos de nuestra base de datos:

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=laravel
DB_USERNAME=root
DB_PASSWORD=

Una vez tenemos la base de datos configurada con nuestros datos deberemos ejecutar las migraciones:

php artisan migrate

Después de ejecutar las migraciones debemos verificar que tenemos la tabla users y password_reset_tokens

Creación del modelo Usuario

Nos dirigimos a app/Models y veremos que tenemos el Modelo User.php

<?php

namespace App\Models;

// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable
{
    use HasApiTokens, HasFactory, Notifiable;

    /**
     * The attributes that are mass assignable.
     *
     * @var array<int, string>
     */
    protected $fillable = [
        'name',
        'email',
        'password',
    ];

    /**
     * The attributes that should be hidden for serialization.
     *
     * @var array<int, string>
     */
    protected $hidden = [
        'password',
        'remember_token',
    ];

    /**
     * The attributes that should be cast.
     *
     * @var array<string, string>
     */
    protected $casts = [
        'email_verified_at' => 'datetime',
        'password' => 'hashed',
    ];
}

Crear las rutas

Lo siguiente será definir las rutas para solicitar la contraseña, enviar el email, formulario para modificar la contraseña y la modificación de la contraseña:

<?php

//web.php

use Illuminate\Support\Facades\Route;
use App\Http\Controllers\AuthController;

Route::get('/', function () {
    return view('welcome');
});

// Formulario donde el usuario pone su email para que le enviemos el email de resetear la contraseña
Route::get('/formulario-recuperar-contrasenia', [AuthController::class, 'formularioRecuperarContrasenia'])->name('formulario-recuperar-contrasenia');

// Función que se ejecuta al enviar el formulario y que enviará el email al usuario
Route::post('/enviar-recuperar-contrasenia', [AuthController::class, 'enviarRecuperarContrasenia'])->name('enviar-recuperacion');

// Formulario donde se modificará la contraseña
Route::get('/reiniciar-contrasenia/{token}', [AuthController::class, 'formularioActualizacion'])->name('formulario-actualizar-contrasenia');

// Función que actualiza la contraseña del usuario
Route::post('/actualizar-contrasenia', [AuthController::class, 'actualizarContrasenia'])->name('actualizar-contrasenia');

Las rutas la encontrarás en routes/web.php

Crear Usuario de Prueba

Creamos un seeder que creará un usuario de prueba(sustituye el email por el que recibirá el email de modificar contrasñea):

php artisan make:seeder UsuariosSeeder
<?php

namespace Database\Seeders;

use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;

class UsuariosSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        User::create([
            'name' => 'Usuario de Prueba',
            'email' => '[email protected]',
            'password' => Hash::make('password'),
        ]);
        
    }
}

Con el Seeder creado y configurado lo siguiente es ejecutarlo:

php artisan db:seed UsuariosSeeder

Configurar Emails

Para poder enviar el email de recuperación de contraseña debemos establecer un servicio de emailing, para ello nos dirigimos al .env:

MAIL_MAILER=smtp
MAIL_HOST=smtp.googlemail.com
MAIL_PORT=465
MAIL_USERNAME=miemail@gmail.com
MAIL_PASSWORD=mipassword
MAIL_ENCRYPTION=ssl
MAIL_FROM_ADDRESS="[email protected]"
MAIL_FROM_NAME="${APP_NAME}"

En este caso es una configuración si vamos a enviar el email de recuperar contraseña desde gmail, en caso de que tengais un servidor de correo u otro servicio deberéis ver sus datos correspondientes


Creación del Controlador AuthController

Vamos a crearnos el controlador que va a tener las funciones que se encargarán de todo el proceso de modificación de contraseña de los usuarios. Primero creamos el controlador Authcontroller:

php artisan make:controller AuthController

Dentro del controlador añadimos el siguiente código:

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use DB;
use Carbon\Carbon;
use App\Models\User;
use Mail;
use Hash;
use Illuminate\Support\Str;

class AuthController extends Controller
{
    /**
     * Función que devuelve la vista con el formulario de recuperar contraseña
     *
     * @return response()
     */
    public function formularioRecuperarContrasenia()
    {
        return view('auth.formulario-recuperar-contrasenia');
    }

    /**
     * Función que recibe el email del usuario y en caso de que exista le envía el email de recuperación de contraseña
     *
     * @return response()
     */
    public function enviarRecuperarContrasenia(Request $request)
    {
        // Validación del email
        $request->validate([
            'email' => 'required|email|exists:users',
        ]);

        // Generamos un token único
        $token = Str::random(64);

        // Eliminamos la anterior reseteo de contraseña sin terminar
        DB::table('password_reset_tokens')->where(['email' => $request->email])->delete();

        // Creamos la solicitud de reseteo de contraseña
        DB::table('password_reset_tokens')->insert([
            'email' => $request->email,
            'token' => $token,
            'created_at' => Carbon::now()
        ]);

        // Enviamos el email de recuperación de contraseña
        Mail::send('email.recuperar-contrasenia', ['token' => $token], function ($message) use ($request) {
            $message->to($request->email);
            $message->subject('Recuperar Contraseña');
        });

        return back()->with('message', 'Te hemos enviado un email con las instrucciones para que recuperes tu contraseña');
    }
    /**
     * Función que devuelve la vista con el formulario que actualiza la contraseña
     *
     * @return response()
     */
    public function formularioActualizacion($token)
    {
        return view('auth.formulario-actualizacion', ['token' => $token]);
    }

    /**
     * Función que actualiza la contraseña del usuario
     *
     * @return response()
     */
    public function actualizarContrasenia(Request $request)
    {
        // Validaciones
        $request->validate([
            'email' => 'required|email|exists:users',
            'password' => 'required|string|min:6|confirmed',
            'password_confirmation' => 'required'
        ]);

        // Obtenemos el registro que contiene la solicitud de reseteo de contraseña
        $updatePassword = DB::table('password_reset_tokens')
            ->where([
                'email' => $request->email,
                'token' => $request->token
            ])
            ->first();

        // Si no existe la solicitud devolvemos un error
        if (!$updatePassword) {
            return back()->withInput()->with('error', 'Token inválido');
        }

        // Actualizamos la contraseña del usuario
        $user = User::where('email', $request->email)
            ->update(['password' => Hash::make($request->password)]);


        // Eliminamos la solicitud
        DB::table('password_reset_tokens')->where(['email' => $request->email])->delete();

        // Devolvemos al formulario de login (devolvera un 404 puesto que no existe la ruta)
        return redirect('/login')->with('message', 'Tu contraseña se ha cambiado correctamente');
    }
}

Creación de Vistas y del Email

Con el email creado ya solo nos queda crear las vistas de los 2 formularios y del email de recuperar contraseña

Creación de Layout

Antes de ponernos con las vistas de los formularios deberemos crear un layput comun a estos formularios. Se encontrará en resources/views/layout.blade.php

<!DOCTYPE html>
<html>

<head>
    <title>Laravel - dCreations.es</title>
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css">
    <style type="text/css">
        @import url(https://fonts.googleapis.com/css?family=Raleway:300,400,600);

        body {
            margin: 0;
            font-size: .9rem;
            font-weight: 400;
            line-height: 1.6;
            color: #212529;
            text-align: left;
            background-color: #f5f8fa;
        }

        .navbar-laravel {
            box-shadow: 0 2px 4px rgba(0, 0, 0, .04);
        }

        .navbar-brand,
        .nav-link,
        .my-form,
        .login-form {
            font-family: Raleway, sans-serif;
        }

        .my-form {
            padding-top: 1.5rem;
            padding-bottom: 1.5rem;
        }

        .my-form .row {
            margin-left: 0;
            margin-right: 0;
        }

        .login-form {
            padding-top: 1.5rem;
            padding-bottom: 1.5rem;
        }

        .login-form .row {
            margin-left: 0;
            margin-right: 0;
        }
    </style>
</head>

<body>

    <nav class="navbar navbar-expand-lg navbar-light navbar-laravel">
        <div class="container">
            <a class="navbar-brand" href="#">Laravel</a>
            <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent"
                aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
                <span class="navbar-toggler-icon"></span>
            </button>

            <div class="collapse navbar-collapse" id="navbarSupportedContent">
                <ul class="navbar-nav ml-auto">
                    @guest
                        <li class="nav-item">
                            <a class="nav-link" href="#">Inicar Sesión</a>
                        </li>
                        <li class="nav-item">
                            <a class="nav-link" href="#">Registro</a>
                        </li>
                    @else
                        <li class="nav-item">
                            <a class="nav-link" href="#">Cerrar Sesión</a>
                        </li>
                    @endguest
                </ul>

            </div>
        </div>
    </nav>

    @yield('content')

</body>

</html>

Formulario de envío del email de recuperar contraseña

Vamos a comenzar creando el formulario que nos pedirá nuestro usuario y una vez lo enviemos enviará el email de recuperar contraseña al usuario. Esta vista se encontrará en resources/views/auth/formulario-recuperar-contrasenia.blade.php

@extends('layout')

@section('content')
    <main class="login-form">
        <div class="cotainer">
            <div class="row justify-content-center">
                <div class="col-md-8">
                    <div class="card">
                        <div class="card-header">Recuperar Contraseña</div>
                        <div class="card-body">

                            @if (Session::has('message'))
                                <div class="alert alert-success" role="alert">
                                    {{ Session::get('message') }}
                                </div>
                            @endif

                            <form action="{{ route('enviar-recuperacion') }}" method="POST">
                                @csrf
                                <div class="form-group row">
                                    <label for="email_address" class="col-md-4 col-form-label text-md-right">Tu Email</label>
                                    <div class="col-md-6">
                                        <input type="text" id="email_address" class="form-control" name="email"
                                            required autofocus>
                                        @if ($errors->has('email'))
                                            <span class="text-danger">{{ $errors->first('email') }}</span>
                                        @endif
                                    </div>
                                </div>
                                <div class="col-md-6 offset-md-4">
                                    <button type="submit" class="btn btn-primary">
                                        Enviar Email de recuperación
                                    </button>
                                </div>
                            </form>

                        </div>
                    </div>
                </div>
            </div>
        </div>
    </main>
@endsection

Formulario de actualización de contraseña

El otro formulario se encargará de modificar la contraseña del usuario. Este formulario se encuentra en resources/views/auth/formulario-actualizacion.blade.php

@extends('layout')
  
@section('content')
<main class="login-form">
  <div class="cotainer">
      <div class="row justify-content-center">
          <div class="col-md-8">
              <div class="card">
                  <div class="card-header">Recuperar Contraseña</div>
                  <div class="card-body">
  
                      <form action="{{ route('actualizar-contrasenia') }}" method="POST">
                          @csrf
                          <input type="hidden" name="token" value="{{ $token }}">
  
                          <div class="form-group row">
                              <label for="email_address" class="col-md-4 col-form-label text-md-right">Email</label>
                              <div class="col-md-6">
                                  <input type="text" id="email_address" class="form-control" name="email" required autofocus>
                                  @if ($errors->has('email'))
                                      <span class="text-danger">{{ $errors->first('email') }}</span>
                                  @endif
                              </div>
                          </div>
  
                          <div class="form-group row">
                              <label for="password" class="col-md-4 col-form-label text-md-right">Contraseña</label>
                              <div class="col-md-6">
                                  <input type="password" id="password" class="form-control" name="password" required autofocus>
                                  @if ($errors->has('password'))
                                      <span class="text-danger">{{ $errors->first('password') }}</span>
                                  @endif
                              </div>
                          </div>
  
                          <div class="form-group row">
                              <label for="password-confirm" class="col-md-4 col-form-label text-md-right">Confirmar Contraseña</label>
                              <div class="col-md-6">
                                  <input type="password" id="password-confirm" class="form-control" name="password_confirmation" required autofocus>
                                  @if ($errors->has('password_confirmation'))
                                      <span class="text-danger">{{ $errors->first('password_confirmation') }}</span>
                                  @endif
                              </div>
                          </div>
  
                          <div class="col-md-6 offset-md-4">
                              <button type="submit" class="btn btn-primary">
                                  Cambiar Contraseña
                              </button>
                          </div>
                      </form>
                        
                  </div>
              </div>
          </div>
      </div>
  </div>
</main>
@endsection

Creación del email

La última vista será la propia del email de recuperación de contraseña. Se encontrará en resources/views/email/recuperar-contrasenia.blade.php

<h1>Email Recuperación de contraseña</h1>

Puedes recuperar tu contraseña a través del siguiente link
<a href="{{ route('formulario-actualizar-contrasenia', $token) }}">Recuperar Contraseña</a>

Ejecución y prueba del proyecto

Por último ejecutamos nuestra web laravel 10 mediante artisan

php artisan serve

Si nos dirigimos a http://127.0.0.1:8000/formulario-recuperar-contrasenia podremos ver nuestro formulario de recuperar contraseña


Consideraciones de seguridad

Implementar la recuperación de contraseña de forma segura es muy importante. Veamos algunos aspectos a tener en cuenta.

Definir tiempo de expiración de tokens

Los tokens deben tener un tiempo limitado de validez, por ejemplo de 1 hora. Así evitamos tokens perpetuos.

Proteger contra ataques de fuerza bruta

Debemos limitar el número de emails de reset que se pueden enviar por hora y el numero de intentos de adivinar tokens para protegernos contra ataques de fuerza bruta.


Conclusión

A lo largo de este artículo hemos aprendido a implementar la funcionalidad de recuperación de contraseña en Laravel de forma completa y segura.

Los puntos clave han sido:

  • - Generar tokens únicos y enviarlos por email al usuario.

  • - Crear un formulario para establecer la nueva contraseña.

  • - Actualizar la contraseña hasheada en la base de datos.

  • - Enviar confirmación por email al usuario.

  • - Proteger contra ataques de fuerza bruta.

  • - Manejar errores y personalizar la experiencia de usuario.

Con la recuperación de contraseña integrada, nuestra aplicación Laravel tendrá mayor seguridad y nuestros usuarios podrán restablecer sus accesos de forma sencilla si alguna vez olvidan sus credenciales.

El proceso de reinicio de contraseñas es una pieza fundamental en cualquier sistema de autenticación moderno. ¡Espero que este artículo te haya resultado útil para implementarlo de forma sólida en tus proyectos!