Skip to content

Autenticación y autorización

HUMAE usa Laravel Sanctum para autenticación y Spatie Permission para roles/permisos, complementado con Policies nativas de Laravel para autorización a nivel de recurso.

Los dos modos de Sanctum

Cuando un frontend first-party (same TLD) consume el backend:

1. Frontend: GET /sanctum/csrf-cookie
   ← Set-Cookie: XSRF-TOKEN=...

2. Frontend: POST /api/v1/auth/login
   Body: {email, password}
   Header: X-XSRF-TOKEN: <del cookie>
   Cookies: XSRF-TOKEN=...

   ← 200 OK
   ← Set-Cookie: humae_session=... (session cookie)
   ← Body: {user, token (opcional, puede no enviarse)}

3. Frontend: cualquier GET/POST posterior
   Cookies: humae_session=..., XSRF-TOKEN=...
   Header: X-XSRF-TOKEN: ...  (requerido para POST/PATCH/DELETE)

   El backend valida la cookie y resuelve $request->user().

Config clave (.env prod):

env
SANCTUM_STATEFUL_DOMAINS=humae.com.mx,www.humae.com.mx
SESSION_DOMAIN=.humae.com.mx
SESSION_DRIVER=redis
SESSION_SECURE_COOKIE=true
SESSION_SAME_SITE=lax

En config/sanctum.php la lista stateful determina qué orígenes reciben el tratamiento SPA.

Modo 2 · Token Bearer — mobile / integraciones

Para clientes que no son first-party:

1. POST /api/v1/auth/login
   Body: {email, password}

   ← 200 OK
   ← Body: {user, token: "1|abc...", token_type: "Bearer"}

2. Todas las requests siguientes:
   Header: Authorization: Bearer 1|abc...

El token se guarda en tabla personal_access_tokens (hash, nunca plain).

Revocar:

php
$request->user()->currentAccessToken()->delete(); // solo el current
// o
$request->user()->tokens()->delete(); // todos

Determinación automática del modo

Sanctum decide basado en:

  • Si viene desde un dominio en SANCTUM_STATEFUL_DOMAINS → SPA mode (cookies).
  • Si no → token mode (Authorization header).

El mismo endpoint de login sirve para ambos.

Endpoints de auth

Todos bajo /api/v1/auth/* en routes/api.php:

EndpointMiddlewareRate limitNotas
POST /registerguest10/minCandidato self-service. Crea User con status=active y devuelve token.
POST /register/recruiterguest5/minReclutador self-service. Crea User con status=pending_approval. No devuelve token. Notifica a admins.
POST /register/companyguest5/minEmpresa self-service. Crea User + Company + CompanyMember(owner), todos pendientes. No devuelve token. Notifica a admins.
POST /loginguest5/minAplica las dos compuertas (verify + approval) descritas abajo.
POST /logoutauth:sanctum60/min
GET /meauth:sanctum120/min
POST /verify-emailguest (con firma)10/minMisma URL para los 4 roles.
POST /verify-email/resendguest3/min
POST /forgot-passwordguest5/min
POST /reset-passwordguest (con token)5/min

Compuertas de login

AuthController::login() evalúa, después de Hash::check(), dos condiciones que devuelven 403 con un errors.code que el frontend reconoce:

Condiciónerrors.codeMensaje
email_verified_at === nullemail_unverified"Verifica tu correo antes de iniciar sesión."
status === 'pending_approval'pending_approval"Tu cuenta está en revisión por un administrador de HUMAE."
status ∈ {suspended, inactive}account_inactive"Tu cuenta está inactiva. Contacta a soporte."

Sólo si pasa las dos compuertas y status === 'active' se emite el token Sanctum.

Endpoints admin para aprobaciones

Bajo /api/v1/admin/users/* (todos requieren rol admin):

EndpointAcción
POST /{user}/approvepending_approvalactive; manda AccountApprovedNotification. 409 si la cuenta no estaba pendiente.
POST /{user}/rejectpending_approvalinactive; manda AccountRejectedNotification con reason opcional (máx. 500 caracteres).

El listado GET /api/v1/admin/users?status=pending_approval filtra exclusivamente las solicitudes pendientes.

Spatie Permission

Docs oficiales: https://spatie.be/docs/laravel-permission

4 roles

Definidos en database/seeders/RolesAndPermissionsSeeder.php:

RolCódigo
Candidatocandidate
Recruiterrecruiter
Company usercompany_user
Adminadmin

45 permisos

Agrupados en 11 categorías. Ver matriz completa en Roles y permisos.

Ejemplos:

users.view, users.create, users.update, users.delete, users.suspend
vacancy.view, vacancy.create, vacancy.update, vacancy.publish, vacancy.cancel, vacancy.delete
assignment.create, assignment.update, assignment.move_stage
interview.schedule, interview.confirm, interview.reschedule, interview.cancel
...

Seed inicial

bash
php artisan db:seed --class=RolesAndPermissionsSeeder

Crea permisos + roles + asignaciones rol→permisos.

Asignar rol a un user

php
$user->assignRole('candidate');
// o
$user->assignRole(UserRole::Candidate->value);

// Cambiar
$user->syncRoles(['recruiter']);

// Quitar
$user->removeRole('candidate');

Verificar rol/permiso

php
$user->hasRole('admin');                        // true/false
$user->hasAnyRole(['admin', 'recruiter']);      // OR
$user->hasAllRoles(['admin', 'recruiter']);     // AND (raro)

$user->can('vacancy.create');                   // permiso directo
$user->hasPermissionTo('vacancy.create');       // sinónimo

Middleware de rol

Registrado como role en bootstrap/app.php:

php
$middleware->alias([
    'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
    'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,
]);

Uso en rutas:

php
Route::middleware(['auth:sanctum', 'role:candidate'])->group(function () {
    Route::get('/me/profile', [ProfileController::class, 'show']);
    Route::patch('/me/profile', [ProfileController::class, 'update']);
    Route::post('/me/profile/avatar', [ProfileController::class, 'updateAvatar']);
});

Route::middleware(['auth:sanctum', 'role:recruiter|admin'])->group(function () {
    Route::get('/directory/candidates', [DirectoryController::class, 'index']);
});

Route::middleware(['auth:sanctum', 'role:admin'])->group(function () {
    Route::apiResource('admin/catalogos/skills', SkillsController::class);
});

Policies

Cuando la autorización depende del recurso (no solo del rol), usa una Policy.

Cuándo Policy vs Permission

SituaciónHerramienta
"¿Puede este user crear vacantes?"Permission (vacancy.create)
"¿Puede este user editar esta vacante (que pertenece a su empresa)?"Policy (VacancyPolicy::update)
"¿Puede este admin ver cualquier perfil?"Role bypass (before() en policy)

Las 5 Policies del MVP

app/Policies/
├── CandidateProfilePolicy.php     view, update, delete (+ admin bypass)
├── CompanyPolicy.php               view, create, update, inviteMembers, delete
├── VacancyPolicy.php               view, update, publish, cancel, delete
├── VacancyAssignmentPolicy.php     view, create, moveStage, addNote, delete
└── InterviewPolicy.php             view, schedule, confirm, reschedule, cancel

Anatomía de una Policy

php
<?php

declare(strict_types=1);

namespace App\Policies;

use App\Models\User;
use App\Models\Vacancy;

final class VacancyPolicy
{
    // OPCIONAL: bypass si es admin, evita duplicar condición en cada método
    public function before(User $user, string $ability): ?bool
    {
        if ($user->hasRole('admin')) {
            return true;
        }
        return null; // continuar con la lógica normal
    }

    public function view(User $user, Vacancy $vacancy): bool
    {
        if ($user->hasRole('recruiter')) {
            return true;
        }

        // company_user solo ve vacantes de su empresa
        if ($user->hasRole('company_user')) {
            return $user->companies()
                ->where('companies.id', $vacancy->company_id)
                ->exists();
        }

        return false;
    }

    public function update(User $user, Vacancy $vacancy): bool
    {
        // Recruiter puede editar cualquier vacante (staff HUMAE)
        if ($user->hasRole('recruiter')) {
            return true;
        }

        // Company user debe pertenecer a la empresa dueña
        if ($user->hasRole('company_user')) {
            return $user->companies()
                ->where('companies.id', $vacancy->company_id)
                ->wherePivotIn('role_in_company', ['owner', 'manager'])
                ->exists();
        }

        return false;
    }

    // ...
}

Auto-registration

Laravel 12 infiere la relación policy↔model por convención:

  • App\Models\VacancyApp\Policies\VacancyPolicy

No es necesario registrarla en AuthServiceProvider::policies (a menos que quieras un namespace no estándar).

Uso en controllers

php
public function update(UpdateVacancyRequest $request, Vacancy $vacancy): JsonResponse
{
    $this->authorize('update', $vacancy); // lanza 403 si false

    $vacancy->update($request->validated());

    return $this->success('Vacante actualizada', VacancyResource::make($vacancy));
}

Uso desde FormRequest

Mejor: mover la autorización al FormRequest:

php
final class UpdateVacancyRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('update', $this->route('vacancy'));
    }

    public function rules(): array { /*...*/ }
}

Así el controller queda aún más delgado.

Uso desde Blade / Resources

php
// Resource
public function toArray(Request $request): array
{
    $user = $request->user();
    return [
        'id' => $this->id,
        'title' => $this->title,
        'can_edit' => $user?->can('update', $this->resource) ?? false,
        'can_delete' => $user?->can('delete', $this->resource) ?? false,
    ];
}

El frontend puede usar can_edit para mostrar/ocultar botones.

Middleware personalizado

EnsureRoleMiddleware

Wrapper del middleware de Spatie con logging adicional. Registrado en bootstrap/app.php.

Útil cuando necesitas trazar intentos de acceso no autorizados.

HandleCors (default Laravel)

Incluido por default. Reading from config/cors.php.

VerifyCsrfToken

Activo solo para rutas web (login/register si hubiera). Las rutas API no usan CSRF (Sanctum usa X-XSRF-TOKEN header en SPA mode que es más estricto).

Session + Remember-me

HUMAE usa sessions Redis en prod:

env
SESSION_DRIVER=redis
SESSION_LIFETIME=120  # minutos
SESSION_EXPIRE_ON_CLOSE=false

Remember-me no está implementado en el MVP (las sessions de 120min se renuevan en cada request activo).

Para implementarlo (Fase 2):

  • Al login, si remember_me=true, llamar Auth::attempt($credentials, true).
  • Laravel genera remember_token en la tabla users.
  • Cookie dura 5 años por default.

Password hashing

Bcrypt con 12 rounds en producción (vs 4 en test):

php
// config/hashing.php
'bcrypt' => [
    'rounds' => env('BCRYPT_ROUNDS', 12),
],

// .env (prod)
BCRYPT_ROUNDS=12

// phpunit.xml (tests)
<env name="BCRYPT_ROUNDS" value="4"/>

Lower rounds en tests acelera la suite significativamente.

Password policy

Validación Zod-equivalente en backend (FormRequest):

php
'password' => [
    'required',
    'string',
    'min:8',
    'regex:/[A-Za-z]/',    // al menos 1 letra
    'regex:/[0-9]/',        // al menos 1 número
    'confirmed',
],

Sincronizado con el schema Zod del frontend (src/schemas/auth.ts).

Verificación de email

Laravel VerifyEmail::createUrlUsing() genera URL firmada con expiración de 60 minutos.

En AppServiceProvider::boot():

php
VerifyEmail::createUrlUsing(function ($notifiable) {
    return config('app.frontend_url')
        . '/verify-email?'
        . http_build_query([
            'id' => $notifiable->getKey(),
            'hash' => sha1($notifiable->getEmailForVerification()),
            'expires' => now()->addMinutes(60)->getTimestamp(),
            'signature' => '...',  // HMAC
        ]);
});

El frontend recibe esos params, los envía al endpoint POST /auth/verify-email que valida la firma y marca email_verified_at.

Password reset

Flujo estándar Laravel:

  1. POST /auth/forgot-password con email → genera token, guarda en password_reset_tokens, envía correo.
  2. El user abre el link del correo → frontend captura token + email.
  3. POST /auth/reset-password con token, email, password, password_confirmation.
  4. Backend valida token, actualiza password hash, revoca todos los Sanctum tokens activos (seguridad).

Gates

Gates custom para lógica muy específica que no cabe en Policy. HUMAE no usa muchos en MVP; si los necesitas, registrar en AuthServiceProvider::boot():

php
Gate::define('access-admin-panel', function (User $user) {
    return $user->hasRole('admin') && $user->email_verified_at !== null;
});

// Uso
if (Gate::allows('access-admin-panel')) { /*...*/ }

Testing de auth

En tests, autentica rápido con Sanctum::actingAs():

php
use Laravel\Sanctum\Sanctum;
use App\Models\User;

it('requires authentication', function () {
    $response = $this->getJson('/api/v1/me/profile');
    $response->assertStatus(401);
});

it('allows candidate to see own profile', function () {
    $user = User::factory()->create();
    $user->assignRole('candidate');
    Sanctum::actingAs($user);

    $response = $this->getJson('/api/v1/me/profile');
    $response->assertStatus(200);
});

Siguiente

La capa de servicios — donde vive la lógica de negocio: Capa de servicios →

Manual de usuario HUMAE · Uso interno