Apariencia
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
Modo 1 · SPA (cookie-based) — frontend web
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=laxEn 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(); // todosDeterminació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:
| Endpoint | Middleware | Rate limit | Notas |
|---|---|---|---|
POST /register | guest | 10/min | Candidato self-service. Crea User con status=active y devuelve token. |
POST /register/recruiter | guest | 5/min | Reclutador self-service. Crea User con status=pending_approval. No devuelve token. Notifica a admins. |
POST /register/company | guest | 5/min | Empresa self-service. Crea User + Company + CompanyMember(owner), todos pendientes. No devuelve token. Notifica a admins. |
POST /login | guest | 5/min | Aplica las dos compuertas (verify + approval) descritas abajo. |
POST /logout | auth:sanctum | 60/min | |
GET /me | auth:sanctum | 120/min | |
POST /verify-email | guest (con firma) | 10/min | Misma URL para los 4 roles. |
POST /verify-email/resend | guest | 3/min | |
POST /forgot-password | guest | 5/min | |
POST /reset-password | guest (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ón | errors.code | Mensaje |
|---|---|---|
email_verified_at === null | email_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):
| Endpoint | Acción |
|---|---|
POST /{user}/approve | pending_approval → active; manda AccountApprovedNotification. 409 si la cuenta no estaba pendiente. |
POST /{user}/reject | pending_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:
| Rol | Código |
|---|---|
| Candidato | candidate |
| Recruiter | recruiter |
| Company user | company_user |
| Admin | admin |
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=RolesAndPermissionsSeederCrea 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ónimoMiddleware 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ón | Herramienta |
|---|---|
| "¿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, cancelAnatomí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=falseRemember-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, llamarAuth::attempt($credentials, true). - Laravel genera
remember_tokenen la tablausers. - 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:
POST /auth/forgot-passwordcon email → genera token, guarda enpassword_reset_tokens, envía correo.- El user abre el link del correo → frontend captura
token+email. POST /auth/reset-passwordcontoken,email,password,password_confirmation.- 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 →

