php elephant sticker
Photo by RealToughCandy.com on Pexels.com
目次

[De principiante a producción] Construir autenticación y autorización en Laravel de la forma correcta

Inicio de sesión/Registro, Sanctum, Policy/Gate, diseño de roles, seguridad y formularios accesibles e interfaz de errores

  • Este artículo es para quienes quieren implementar login en Laravel pero no están seguros de cuál es “la forma correcta”. Organiza autenticación (Auth) y autorización (Authorization) como un único diseño continuo.
  • Como mejora a la vez la UI (formularios) y el backend (sesiones/tokens/permisos), te ayuda a evitar errores comunes del mundo real.
  • Incluye ejemplos prácticos que tratan la accesibilidad (lectores de pantalla, navegación por teclado, presentación de errores) como el estándar por defecto.

Lectores previstos (¿a quién beneficia?)

  • Nuevos en Laravel: no entiendes las diferencias entre Breeze/Jetstream/Sanctum y no sabes por dónde empezar
  • Desarrolladores de apps web pequeñas/medianas: el login/permisos se volvió ad hoc y operaciones empieza a doler
  • Leads/revisores: quieres reglas de autorización consistentes en código y menos “accidentes” de UI (ver datos que no deberías)
  • Diseñadores/QA/accesibilidad: quieres formularios y flujos de login en los que nadie se quede atascado

Nivel de accesibilidad: ★★★★★

  • Asociación correcta etiqueta–input, resúmenes de error, aria-invalid / aria-describedby, y señales de requerido/error que no dependen del color
  • Guía amable para expiración de sesión (419) y permisos insuficientes (403), incluyendo el “siguiente paso”
  • Diseñado para que los usuarios completen login → cambios de configuración solo con teclado

1. Autenticación y autorización son distintas, pero diseñarlas juntas te hace más fuerte

Cuando empiezas a construir una app en Laravel, lo primero que normalmente quieres es el login. Pero justo después de que el login funciona, aparece la siguiente pregunta inevitable: “¿Esta persona puede hacer esto?” Si pospones la autorización, cuantas más pantallas y APIs agregues, más verificaciones se te van a escapar. Así ocurren los incidentes de permisos (ver datos que no deberías, editar cuando no corresponde).

Así que desde el inicio, trátalas como un par:

  • Autenticación: ¿quién eres? (identidad vía login/tokens)
  • Autorización: ¿qué se te permite hacer? (chequeos de permisos, de propiedad, roles)

El objetivo de este artículo es ayudar a principiantes a pasar de “funciona” a una configuración de auth que puedas operar con seguridad en producción.


2. Primera decisión: Breeze vs Jetstream vs Fortify vs hacerlo tú mismo

Laravel ofrece varios enfoques. En la mayoría de los casos, el primer paso más seguro y simple es Breeze.

  • Laravel Breeze
    • Minimalista; incluye login/registro/restablecimiento de contraseña, etc.
    • La versión Blade es fácil de leer y amigable para principiantes
  • Laravel Jetstream
    • Muy completo (equipos, 2FA, etc.)
    • Excelente si necesitas todo desde el día 1, pero el costo de aprendizaje es mayor
  • Laravel Fortify
    • Backend de auth sin UI (para SPA o UI personalizada)
  • Implementación personalizada
    • Buena para aprender, pero arriesgada en producción: puntos críticos (CSRF, fijación de sesión, límites de tasa, etc.) son fáciles de omitir

Si quieres el camino más corto hacia una base segura: construye la base con Breeze (Blade) y luego amplía la autenticación de API con Sanctum cuando lo necesites.


3. Fundamentos de autenticación: flujo de login por sesión (apps web)

3.1 Qué ocurre cuando el login tiene éxito

Para apps web basadas en navegador, el flujo estándar es:

  1. El formulario envía email + contraseña
  2. El servidor verifica la identidad (verificación del hash)
  3. Si tiene éxito, se crea una sesión y el ID de sesión mantiene el estado de login
  4. La protección CSRF trabaja junto con los formularios para mantener los envíos seguros

Laravel soporta muy bien este mecanismo estándar, así que normalmente es más seguro no sobre-personalizarlo.

3.2 Ajustes importantes en .env

  • Asegúrate de que APP_KEY esté generado
  • En producción: APP_DEBUG=false
  • Si eliges Redis (etc.) para sesiones, incluye consideraciones operativas también

4. Diseño de contraseñas: fortaleza, flujo de reset y mensajes estandarizados

Las contraseñas son centrales para la seguridad, pero reglas demasiado estrictas aumentan la carga de soporte. En la práctica, “un mínimo razonable + un flujo de restablecimiento bien diseñado” funciona mejor.

4.1 Ejemplo de validación (FormRequest)

// app/Http/Requests/RegisterRequest.php
class RegisterRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'name' => ['required','string','max:50'],
            'email' => ['required','email','max:255','unique:users,email'],
            'password' => [
                'required','string','min:12','confirmed',
            ],
        ];
    }

    public function attributes(): array
    {
        return [
            'name' => 'Your name',
            'email' => 'Email address',
            'password' => 'Password',
        ];
    }
}

4.2 El texto del reset debe reducir “incidentes”

  • Aunque el email no exista, muestra “Lo enviamos” (no revelar existencia de cuenta)
  • Indica claramente la expiración y guía al usuario a reemitir si expiró
  • Ofrece email en HTML y texto plano (robusto para tecnologías de asistencia y entornos variados)

5. Fundamentos de autorización: centraliza el “puede/no puede” con Policies y Gates

La autorización se rompe cuando se dispersa en if dentro de la UI. En Laravel, organizarse alrededor de estos dos es el enfoque más limpio:

  • Policy: permisos para un modelo (p. ej., Post/Project/Order)
  • Gate: decisiones sin un modelo (acceso a consola admin, feature flags, etc.)

5.1 Crear una Policy

Ejemplo: definir si un usuario puede editar un proyecto.

php artisan make:policy ProjectPolicy --model=Project
// app/Policies/ProjectPolicy.php
class ProjectPolicy
{
    public function view(User $user, Project $project): bool
    {
        return $user->tenant_id === $project->tenant_id;
    }

    public function update(User $user, Project $project): bool
    {
        if ($user->tenant_id !== $project->tenant_id) return false;
        return $user->role === 'admin' || $user->role === 'owner';
    }

    public function delete(User $user, Project $project): bool
    {
        return $user->tenant_id === $project->tenant_id
            && $user->role === 'owner';
    }
}

5.2 Usarla en controladores (un “portero” unificado)

public function edit(Project $project)
{
    $this->authorize('update', $project);
    return view('projects.edit', compact('project'));
}

Esto mantiene la lógica de autorización en un solo lugar. Las revisiones se vuelven “revisa la Policy”, y los chequeos omitidos bajan drásticamente.


6. Diseño de roles: empieza pequeño, mantén significados explícitos

Demasiados roles se vuelven inmanejables. Un inicio simple de 4 niveles suele ser práctico:

  • owner: facturación, eliminación, máximo privilegio
  • admin: configuración y gestión de miembros
  • member: operaciones normales
  • viewer: solo lectura

Lo importante: documenta cada rol y aplícalo en código (Policy). Ocultar botones no es seguridad: valida siempre del lado servidor con authorize.


7. Autenticación de API: usa Sanctum para SPA y clientes externos

A veces quieres APIs más allá de las sesiones del navegador. Sanctum es el valor por defecto más fácil en Laravel.

7.1 Dos estilos de uso

  • Auth para SPA (SPA en el mismo dominio)
    • Sesión + CSRF, pero accediendo vía API
  • Tokens de acceso personal (clientes externos)
    • Emitir tokens y autenticar con Authorization: Bearer

7.2 Ejemplo de emisión de token (clientes externos)

$token = $user->createToken('cli', ['orders:read'])->plainTextToken;
return response()->json(['token' => $token]);

7.3 Proteger rutas

Route::middleware('auth:sanctum')->get('/api/v1/orders', function () {
    return OrderResource::collection(Order::latest()->paginate());
});

Los scopes/abilities reducen el radio de impacto si se filtra un token.


8. Rate limiting: protege siempre login/reset/invitaciones

El login y el restablecimiento de contraseña son objetivos comunes de ataque. Considera el rate limiting como obligatorio.

Route::post('/login', [AuthController::class, 'store'])->middleware('throttle:10,1');
  • Elige valores realistas (p. ej., máximo 10 por minuto)
  • Para 429, guía a los usuarios: “Espera e inténtalo de nuevo.”
  • Evita mensajes que revelen si un email existe

9. Formularios de login accesibles: patrón estándar para etiquetas, errores y foco

Las pantallas de auth las visita casi todo el mundo, así que la accesibilidad tiene un ROI alto. Aquí tienes un “formulario estándar” mínimo y efectivo.

9.1 Resumen de errores (validación del servidor)

@if ($errors->any())
  <div id="error-summary" role="alert" tabindex="-1" class="border p-3 mb-4">
    <h2 class="font-semibold">Please review your input.</h2>
    <ul class="list-disc pl-5">
      @foreach ($errors->all() as $error)
        <li>{{ $error }}</li>
      @endforeach
    </ul>
  </div>

  <script>
    (function(){
      const el = document.getElementById('error-summary');
      if (el) el.focus();
    })();
  </script>
@endif
  • Mover el foco al resumen ayuda a usuarios de lectores de pantalla a entender de inmediato qué pasó
  • No dependas solo del color rojo: usa explicación en texto

9.2 Vincular inputs con errores

@php $emailError = $errors->first('email'); @endphp

<label for="email" class="block font-medium">
  Email <span class="sr-only">Required</span><span aria-hidden="true">(Required)</span>
</label>
<input id="email" name="email" type="email" value="{{ old('email') }}"
  autocomplete="email"
  aria-invalid="{{ $emailError ? 'true' : 'false' }}"
  aria-describedby="{{ $emailError ? 'email-error' : 'email-help' }}"
  class="border rounded px-3 py-2 w-full">

<p id="email-help" class="text-sm text-gray-600">Example: hanako@example.com</p>
@if($emailError)
  <p id="email-error" class="text-sm text-red-700">{{ $emailError }}</p>
@endif

Haz lo mismo para la contraseña. Una vez que lo conviertas en componentes Blade, todos los formularios serán consistentes y seguros.


10. Humaniza las “pantallas frustrantes” (403/419/503)

Los usuarios suelen confundirse con errores de permisos y expiración de sesión. Pulir esto reduce tickets de soporte y aumenta la confianza.

  • 403 (Prohibido)
    • Explica brevemente qué está bloqueado y ofrece siguientes pasos (“Volver”, “Solicitar acceso”, etc.)
  • 419 (Sesión expirada / CSRF)
    • Explica “Tu sesión expiró por inactividad” y ofrece una ruta clara para volver a iniciar sesión
  • 503 (Mantenimiento / caída temporal)
    • Da un ETA si es posible, alcance del impacto y una alternativa de soporte

Para accesibilidad, usa encabezados claros y texto de enlaces específico. Evita “Haz clic aquí”; usa “Volver al inicio de sesión”, etc.


11. Refleja la autorización en la UI (pero la Policy sigue siendo la defensa final)

Ocultar o mostrar botones “Editar” importa para la experiencia, pero no es seguridad.

  • Mostrar: @can para UX
  • Ejecutar: authorize para bloquear de verdad
    Este enfoque de dos capas es estable.
@can('update', $project)
  <a href="{{ route('projects.edit', $project) }}">Edit</a>
@endcan

12. Tests: evita incidentes de permisos en CI

La autorización tiene alto impacto, así que los tests de Feature valen la pena.

12.1 Un usuario distinto no puede actualizar

public function test_user_cannot_update_other_users_project()
{
    $t1 = Tenant::factory()->create();
    $t2 = Tenant::factory()->create();

    $user = User::factory()->create(['tenant_id' => $t1->id, 'role' => 'admin']);
    $otherProject = Project::factory()->create(['tenant_id' => $t2->id]);

    $this->actingAs($user);
    app()->instance('tenant', $t1);

    $res = $this->patch("/projects/{$otherProject->id}", ['name' => 'x']);
    $res->assertForbidden();
}

12.2 Solo owner puede eliminar

public function test_only_owner_can_delete()
{
    $t = Tenant::factory()->create();
    $owner = User::factory()->create(['tenant_id'=>$t->id, 'role'=>'owner']);
    $admin = User::factory()->create(['tenant_id'=>$t->id, 'role'=>'admin']);
    $p = Project::factory()->create(['tenant_id'=>$t->id]);

    $this->actingAs($admin);
    app()->instance('tenant', $t);
    $this->delete("/projects/{$p->id}")->assertForbidden();

    $this->actingAs($owner);
    $this->delete("/projects/{$p->id}")->assertRedirect();
}

Incluso solo estos dos tests previenen muchos fallos comunes de autorización.


13. Errores comunes y cómo evitarlos

  • La autorización varía por pantalla
    • Centraliza en Policies y estandariza authorize en controladores
  • Sensación de seguridad porque los botones están ocultos
    • La defensa final es del lado servidor: siempre authorize
  • Demasiados roles
    • Empieza con ~4 roles y expande solo cuando sea necesario
  • El texto del reset filtra si un usuario existe
    • Usa la misma respuesta independientemente de la existencia
  • Pantallas 419/403 poco útiles
    • Siempre ofrece siguientes pasos (re-login, volver, solicitar acceso)
  • Errores mostrados solo con color
    • Usa resúmenes, mensajes de texto y enlaces aria-*

14. Resumen: cuando auth se vuelve un “sistema”, tu app crece sin romperse

Con Breeze y Sanctum puedes construir rápidamente una base segura de autenticación. Al superponer Policies/Gates para unificar “qué está permitido” en el código, reduces incidentes de permisos incluso cuando se multiplican las pantallas. Y al hacer que los formularios de login y la UI de errores sean accesibles por defecto, reduces fricción de usuarios y carga de soporte, facilitando desarrollo y operaciones.

Si estandarizas pronto estas cuatro cosas:

  • Autenticación (login/reset)
  • Autorización (centralizar en Policy, aplicar authorize)
  • Rate limiting (proteger flujos de auth)
  • Formularios accesibles (labels/errores/foco)

…tu equipo obtiene una base que permite crecer en funcionalidades con confianza.


Referencias

por greeden

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)