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_approval → active; manda AccountApprovedNotification. 409 si la cuenta no estaba pendiente.
POST /{user}/rejectpending_approval → inactive; 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\Vacancy ↔ App\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