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.
Al terminar sabrás
- Explicar el contrato
AuthorizationManagery 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,@PostAuthorizey 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.
AuthorizationFilter¿puede este usuario usar esta URL?hasRolehasAuthorityaccess(...)- Proxy de método¿puede llamar a este método con estos argumentos?
@PreAuthorize@PostAuthorize - Consulta¿qué datos le pertenecen?
findByIdAndOwnerfindAllByOwner
El contrato único: AuthorizationManager
Sección titulada «El contrato único: AuthorizationManager»@FunctionalInterfacepublic interface AuthorizationManager<T> { AuthorizationResult authorize(Supplier<? extends Authentication> authentication, T object);}Tes lo que se protege: unRequestAuthorizationContext(la petición y las variables de la URL) en la capa web, o unMethodInvocationen la capa de métodos.- La
Authenticationllega comoSupplierpara no cargarla si la regla no la necesita (por ejemplo,permitAll). - Devuelve una
AuthorizationDecision(que implementaAuthorizationResult). En la 7.0 desapareció el antiguocheck(...)y toda la API deAccessDecisionManagery voters (movida al módulospring-security-accesspara quien necesite migrar).
Cada hasRole(...), authenticated() o permitAll() que escribes en el DSL es, simplemente, un AuthorizationManager ya hecho.
Roles frente a authorities
Sección titulada «Roles frente a authorities»Para Spring Security ambos son GrantedAuthority: un string. La diferencia es una convención:
hasRole("ADMIN")busca la authorityROLE_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.
Jerarquía de roles
Sección titulada «Jerarquía de roles»Para no repetir hasAnyRole("ADMIN", "SUPPORT", "USER") por todas partes:
@Beanstatic 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.
Capa web: reglas con lógica propia
Sección titulada «Capa web: reglas con lógica propia»Cuando una regla no cabe en hasRole, usa access(...) con una lambda. Las variables del patrón llegan en el contexto:
.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())Capa de métodos: @EnableMethodSecurity
Sección titulada «Capa de métodos: @EnableMethodSecurity»@Configuration@EnableMethodSecurity // activa @PreAuthorize, @PostAuthorize, @PreFilter y @PostFilterclass MethodSecurityConfig { }| Anotación | Cuándo evalúa | Uso típico |
|---|---|---|
@PreAuthorize("...") | Antes de ejecutar el método | Roles, permisos, comparar parámetros con el usuario |
@PostAuthorize("...") | Después, con returnObject disponible | Verificar el dueño del objeto devuelto |
@PreFilter / @PostFilter | Filtran colecciones de entrada / salida | Listas pequeñas; en listas grandes, filtra en la consulta |
@Secured("ROLE_ADMIN") | Antes | Legado; requiere securedEnabled = true |
Las expresiones son SpEL y tienen acceso a authentication, principal, los parámetros con #nombre, returnObject y cualquier bean con @nombreBean:
@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:
@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).
IDOR: el dueño del recurso
Sección titulada «IDOR: el dueño del recurso»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:
- 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. @PostAuthorizesobre el objeto cargado. Correcto, pero responde 403 (revela que existe) y carga datos que luego descarta.@PreAuthorizecon 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.
- Entidad
Note(id,title,content,owner,createdAt) y repositorio confindAllByOwner(String owner)yfindByIdAndOwner(Long id, String owner). NoteServicecon:
public List<Note> mine(Authentication auth) { return repo.findAllByOwner(auth.getName());}
@IsAdminpublic 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);}NoteSecurityes un@Component("noteSecurity")conboolean isOwner(Long id, Authentication auth)que consulta el repositorio.- Endpoints:
GET /api/notes,GET /api/notes/{id},POST /api/notes(elownersale deauth.getName()),DELETE /api/notes/{id}yGET /api/admin/notes. - Añade la jerarquía
ROLE_ADMIN > ROLE_USER.
Preguntas:
- Con
anacrea una nota; con otro usuario intentaGETyDELETEsobre 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
deletedesde otro método deNoteServiceconthis.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
GETdevuelve 404: la consultafindByIdAndOwnerno encuentra la nota para ese usuario, así que para él no existe.DELETEdevuelve 403:@PreAuthorizeevalúaisOwner, dafalse, y se lanzaAccessDeniedExceptioncon un usuario autenticado. Son distintos porque usan estrategias distintas; si quieres que el borrado tampoco revele la existencia, cambiadeletepara que busque confindByIdAndOwnery lance 404.- Queda a nombre de quien está autenticado, si el DTO de entrada no tiene campo
ownery el servicio lo toma deauth.getName(). Si quedara a nombre deroot, tendrías una vulnerabilidad de asignación masiva. - No: es una llamada interna que no pasa por el proxy AOP, así que
@PreAuthorizeno se evalúa. Por eso las comprobaciones que no deben saltarse nunca conviene ponerlas en la consulta, o en un bean distinto.