# 🚩 Ejercicio 9 · El recibo del vecino

**Clase 9 — Autenticación y autorización**
· Dificultad: 🔴 alto · Tiempo estimado: 75–90 min

---

## La situación

El portal de recibos del Liquidador funciona. Cada agente entra con su usuario
y ve su recibo. Nadie se quejó nunca.

Este ejercicio corre el ataque que nadie corrió, y lo imprime:

```
  2 · Informe de intrusión     Luis Paz entra con SUS credenciales y prueba suerte

      LO QUE INTENTA                          OBTIENE   DEBERÍA
  ✘   Entrar sin ningún token                 200       401     ← entró
  ✘   Entrar con un token inventado           200       401     ← entró
  ✘   Leer el recibo de Mora Sosa             200       403     ← se llevó $845.000,00
  ✘   Abrir el reporte de masa salarial       200       403     ← se llevó $3.075.000,00
  ✘   Reusar su token tras cerrar sesión      200       401     ← el token sigue vivo
```

Luis no es un hacker. Es un empleado con su usuario legítimo que cambió un
número en la URL. Eso tiene nombre —**IDOR**, *Insecure Direct Object
Reference*— y es de las fallas más comunes que existen, justamente porque no se
ve: la aplicación no muestra el link, así que parece que no está ahí. Pero la
URL sigue estando.

Y antes de eso, el ejercicio te muestra qué pasa si alguien se lleva la base:

```
  USUARIO       ROL        COLUMNA password
  Ana           rrhh       ipap2025           ← se lee tal cual
  Luis          empleado   ipap2025           ← se lee tal cual
  Mora          empleado   liquidacion.2025   ← se lee tal cual
```

No hace falta un backup robado: alcanza con un volcado pegado en un ticket.

## Tus cinco tareas

| # | Dónde | Qué |
|---|---|---|
| 1 | `app/Servicios/Autenticador.php` | Guardar las contraseñas **hasheadas** |
| 2 | `app/Middleware/Autenticar.php` | Frenar al que no se identificó → **401** |
| 3 | `app/Politicas/ReciboPolicy.php` | El recibo es del dueño **o** de RRHH → **403** |
| 4 | idem | El reporte de masa salarial, solo RRHH |
| 5 | `app/Servicios/Autenticador.php` | Que cerrar sesión **invalide el token** |

## Cómo correrlo

```bash
docker compose run --rm ejercicio
```

Con los cinco pasos en verde aparece tu bandera `ipap{c09-xxxxxxxxxx}`, que
—como siempre— **no está escrita en ningún archivo**.

> **Sin Docker:** `cd src && php index.php`
>
> Tarda unos segundos: hashear una contraseña es lento **a propósito**. Esa
> lentitud es la defensa. Si fuera instantáneo, probar millones de contraseñas
> también lo sería.

## Lo que vas a practicar

| Paso | Concepto de la clase |
|------|----------------------|
| 1 | **Hashing** de contraseñas: `password_hash` / `password_verify`, y por qué lleva sal |
| 2 | **Autenticación**: el middleware que responde "quién sos" · **401** |
| 3–4 | **Autorización**: Policies y roles, "qué podés hacer" · **403** |
| 5 | **Ciclo de vida del token**: revocar es parte de tener sesiones |

## Cómo verifica este ejercicio

El verificador **se comporta como un atacante**: se loguea como Luis con sus
credenciales de verdad y desde ahí intenta todo lo que no debería poder hacer.
No alcanza con que la aplicación ande: tiene que **resistir**.

Y cada permiso se prueba de los dos lados. Un sistema que le dice que no a todo
el mundo es tan inútil como uno que le dice que sí, así que cada paso comprueba
también que **quien sí tiene derecho pueda entrar**:

| Se prueba que NO pueda | …y también que SÍ pueda |
|---|---|
| Luis lea el recibo de Mora | Luis lea **el suyo** |
| Luis abra el reporte | **Ana (RRHH)** lo abra |
| Luis use su token viejo | **La sesión de Ana siga viva** |

Esa última fila atrapa un error muy fácil de cometer: "cerrar la sesión"
vaciando la tabla de tokens entera, que funciona… y de paso echa a todos los
usuarios conectados del sistema.

## Si te trabás

1. **Las contraseñas.** Son dos funciones de PHP y no hay que inventar nada:
   `password_hash($clave, PASSWORD_DEFAULT)` para guardar y
   `password_verify($clave, $hash)` para comparar. Nunca con `===`: el mismo
   texto da un hash distinto cada vez, porque cada uno lleva su propia sal.
2. **`md5()` y `sha1()` no sirven.** Son rapidísimas —exactamente lo que no
   querés— y no llevan sal. Fijate en la corrida: Ana y Luis tienen la misma
   contraseña, y el ejercicio te dice si en la base quedaron iguales.
3. **401 vs 403.** No son intercambiables. `401` = *no sé quién sos*: falta el
   token o no sirve. `403` = *sé perfectamente quién sos, y eso no es tuyo*.
   Mandar un 401 cuando el problema es de permisos deja al usuario logueándose
   una y otra vez sin entender por qué.
4. **Las dos condiciones de la policy.** Si escribís solo "es el dueño", RRHH
   deja de poder trabajar. Si escribís solo "es de RRHH", nadie ve su propio
   recibo. Se verifican las dos.
5. **El logout.** Tiene que matar **ese** token. `BD::$tokens[$token]` es la
   entrada que sobra.

---

## Lo interesante

Fijate dónde quedó cada decisión cuando terminaste.

El **controller no cambió** en todo el ejercicio: ya preguntaba
`ReciboPolicy::puedeVer(...)` y respetaba la respuesta. Lo que estaba mal era
la respuesta. Esa es la ventaja de tener las policies aparte: la regla de quién
ve qué vive en un solo archivo, se lee de un vistazo y se puede auditar. Cuando
esa misma regla está repartida en quince `if` adentro de quince controllers, no
hay forma de saber si falta uno — y siempre falta uno.

Un detalle más, para cuando quieras hilar fino. En la corrida vas a ver que
pedir un recibo **inexistente** devuelve `404` y uno **ajeno** devuelve `403`.
Es lo natural y es lo que verifica el ejercicio, pero notá lo que acabás de
regalar: un atacante que compara las dos respuestas puede averiguar **qué
recibos existen** aunque no pueda leer ninguno. Por eso hay sistemas que
contestan `404` en los dos casos. No hay una respuesta correcta universal:
es un intercambio entre claridad y discreción, y conviene tomarlo a propósito
en vez de por defecto.

> En Laravel esto se escribe casi igual. El hashing lo hace el modelo `User`
> con el cast `'password' => 'hashed'`; el middleware es `auth:sanctum`; la
> policy es una clase con métodos `view()`, `update()`, etc.; y el logout es
> `$request->user()->currentAccessToken()->delete()` — fijate que dice
> **current**, por la misma razón de la tabla de arriba.
