Ir al contenido

Spring SecurityLección 07

Autorización por URL y por método

Dos capas que se complementan con un mismo contrato, AuthorizationManager. Roles frente a authorities, jerarquías, reglas propias, @PreAuthorize y la defensa contra IDOR.

Avanzado20 min de lectura

Al terminar sabrás

  • Explicar el contrato AuthorizationManager y cómo encaja en URLs y métodos.
  • Decidir cuándo usar roles, authorities finas o jerarquías de roles.
  • Proteger métodos con @PreAuthorize, @PostAuthorize y meta-anotaciones.
  • Evitar el IDOR filtrando por dueño en la consulta.

Autorizar se hace en dos capas que se complementan: por URL, en la cadena de filtros (grano grueso: “solo admins entran en /api/admin/**”), y por método, con anotaciones (grano fino: “solo el dueño de la nota 42 puede borrarla”). Las dos usan el mismo contrato.

  1. AuthorizationFilter¿puede este usuario usar esta URL?hasRolehasAuthorityaccess(...)
  2. Proxy de método¿puede llamar a este método con estos argumentos?@PreAuthorize@PostAuthorize
  3. Consulta¿qué datos le pertenecen?findByIdAndOwnerfindAllByOwner
@FunctionalInterface
public interface AuthorizationManager<T> {
AuthorizationResult authorize(Supplier<? extends Authentication> authentication, T object);
}
  • T es lo que se protege: un RequestAuthorizationContext (la petición y las variables de la URL) en la capa web, o un MethodInvocation en la capa de métodos.
  • La Authentication llega como Supplier para no cargarla si la regla no la necesita (por ejemplo, permitAll).
  • Devuelve una AuthorizationDecision (que implementa AuthorizationResult). En la 7.0 desapareció el antiguo check(...) y toda la API de AccessDecisionManager y voters (movida al módulo spring-security-access para quien necesite migrar).

Cada hasRole(...), authenticated() o permitAll() que escribes en el DSL es, simplemente, un AuthorizationManager ya hecho.

Para Spring Security ambos son GrantedAuthority: un string. La diferencia es una convención:

  • hasRole("ADMIN") busca la authority ROLE_ADMIN; User.roles("ADMIN") la crea con el prefijo.
  • hasAuthority("notes:delete") busca el string exacto.

Los roles describen quién es el usuario (ADMIN, USER, SUPPORT); las authorities finas describen qué puede hacer (notes:read, notes:delete). En sistemas pequeños bastan roles. Cuando empiezan a multiplicarse, pasa a permisos y asígnalos a roles en la base de datos. Con OAuth2, los scopes llegan como SCOPE_notes:read.

Para no repetir hasAnyRole("ADMIN", "SUPPORT", "USER") por todas partes:

SecurityConfig.java
@Bean
static RoleHierarchy roleHierarchy() {
return RoleHierarchyImpl.fromHierarchy("""
ROLE_ADMIN > ROLE_SUPPORT
ROLE_SUPPORT > ROLE_USER
""");
}

Con este bean, un ADMIN pasa cualquier regla hasRole("USER"). Se aplica a la capa web y a la de métodos.

Cuando una regla no cabe en hasRole, usa access(...) con una lambda. Las variables del patrón llegan en el contexto:

SecurityConfig.java
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/users/{username}/**").access((authentication, ctx) -> {
String target = ctx.getVariables().get("username");
Authentication auth = authentication.get();
boolean self = auth.getName().equals(target);
boolean admin = auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"));
return new AuthorizationDecision(self || admin);
})
.anyRequest().authenticated())
MethodSecurityConfig.java
@Configuration
@EnableMethodSecurity // activa @PreAuthorize, @PostAuthorize, @PreFilter y @PostFilter
class MethodSecurityConfig { }
AnotaciónCuándo evalúaUso típico
@PreAuthorize("...")Antes de ejecutar el métodoRoles, permisos, comparar parámetros con el usuario
@PostAuthorize("...")Después, con returnObject disponibleVerificar el dueño del objeto devuelto
@PreFilter / @PostFilterFiltran colecciones de entrada / salidaListas pequeñas; en listas grandes, filtra en la consulta
@Secured("ROLE_ADMIN")AntesLegado; requiere securedEnabled = true

Las expresiones son SpEL y tienen acceso a authentication, principal, los parámetros con #nombre, returnObject y cualquier bean con @nombreBean:

NoteService.java
@PreAuthorize("hasRole('ADMIN') or #username == authentication.name")
public List<Note> notesOf(String username) { … }
@PostAuthorize("returnObject.owner == authentication.name")
public Note load(Long id) { … }
@PreAuthorize("@noteSecurity.isOwner(#id, authentication)")
public void delete(Long id) { … }

Meta-anotaciones para no repetir strings de SpEL:

IsAdmin.java
@Target({ ElementType.METHOD, ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasRole('ADMIN')")
public @interface IsAdmin { }

Si la expresión da false, se lanza AccessDeniedException, que termina en ExceptionTranslationFilter (salvo que tu @ControllerAdvice la intercepte: repasa 401 frente a 403).

Una regla como /api/notes/** → authenticated() comprueba que hay alguien autenticado, pero la URL no sabe de quién es la nota 42. Esa información está en la base de datos, así que la comprobación tiene que ocurrir donde se carga el dato. Tres estrategias, de más a menos recomendable:

  1. Filtrar en la consulta: noteRepository.findByIdAndOwner(id, auth.getName()). Si no es tuya, para el sistema simplemente no existe: devuelves 404 y no revelas que la nota 42 existe.
  2. @PostAuthorize sobre el objeto cargado. Correcto, pero responde 403 (revela que existe) y carga datos que luego descarta.
  3. @PreAuthorize con un bean que consulta el dueño. Útil cuando la regla es compleja (notas compartidas, equipos, roles por recurso).

Evita

@GetMapping("/api/notes/{id}")
Note get(@PathVariable Long id) {
return repo.findById(id).orElseThrow();
}
@PostMapping("/api/notes")
Note create(@RequestBody Note note) {
return repo.save(note); // ¿y si trae "owner": "root"?
}

Prefiere

@GetMapping("/api/notes/{id}")
Note get(@PathVariable Long id, Authentication auth) {
return repo.findByIdAndOwner(id, auth.getName())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
}
@PostMapping("/api/notes")
Note create(@Valid @RequestBody NewNote body, Authentication auth) {
return repo.save(new Note(body.title(), body.content(), auth.getName()));
}

Mini lab · 35 min

Notas con dueño

Objetivo: Un CRUD de notas en el que cada usuario solo ve y modifica las suyas, y un admin puede listarlas todas.

  1. Entidad Note (id, title, content, owner, createdAt) y repositorio con findAllByOwner(String owner) y findByIdAndOwner(Long id, String owner).
  2. NoteService con:
NoteService.java
public List<Note> mine(Authentication auth) {
return repo.findAllByOwner(auth.getName());
}
@IsAdmin
public List<Note> all() {
return repo.findAll();
}
public Note get(Long id, Authentication auth) {
return repo.findByIdAndOwner(id, auth.getName())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
}
@PreAuthorize("@noteSecurity.isOwner(#id, authentication)")
public void delete(Long id) {
repo.deleteById(id);
}
  1. NoteSecurity es un @Component("noteSecurity") con boolean isOwner(Long id, Authentication auth) que consulta el repositorio.
  2. Endpoints: GET /api/notes, GET /api/notes/{id}, POST /api/notes (el owner sale de auth.getName()), DELETE /api/notes/{id} y GET /api/admin/notes.
  3. Añade la jerarquía ROLE_ADMIN > ROLE_USER.

Preguntas:

  • Con ana crea una nota; con otro usuario intenta GET y DELETE sobre ese id. ¿Qué código devuelve cada uno y por qué son distintos?
  • Intenta crear una nota enviando "owner": "root" en el JSON. ¿A nombre de quién queda?
  • Llama a delete desde otro método de NoteService con this.delete(id). ¿Se verifica el dueño?
Pista

Para la primera pregunta, compara cómo protege cada método: uno filtra en la consulta y el otro usa una anotación.

Mostrar respuestaOcultar respuesta
  • GET devuelve 404: la consulta findByIdAndOwner no encuentra la nota para ese usuario, así que para él no existe. DELETE devuelve 403: @PreAuthorize evalúa isOwner, da false, y se lanza AccessDeniedException con un usuario autenticado. Son distintos porque usan estrategias distintas; si quieres que el borrado tampoco revele la existencia, cambia delete para que busque con findByIdAndOwner y lance 404.
  • Queda a nombre de quien está autenticado, si el DTO de entrada no tiene campo owner y el servicio lo toma de auth.getName(). Si quedara a nombre de root, tendrías una vulnerabilidad de asignación masiva.
  • No: es una llamada interna que no pasa por el proxy AOP, así que @PreAuthorize no se evalúa. Por eso las comprobaciones que no deben saltarse nunca conviene ponerlas en la consulta, o en un bean distinto.